ShipStation is a leading web-based shipping solution that helps e-commerce businesses manage their orders, shipments, and logistics. This comprehensive guide covers integrating ShipStation's REST API into Java applications.
Overview of ShipStation Features
Key Capabilities:
- Order Management - Import, update, and manage orders
- Shipping Labels - Generate shipping labels for multiple carriers
- Rate Shopping - Compare shipping rates across carriers
- Fulfillment - Manage order fulfillment workflows
- Warehouse Management - Multi-warehouse inventory and shipping
- Customs Documentation - International shipping compliance
- Webhooks - Real-time notifications for order events
Supported Carriers:
- USPS, UPS, FedEx, DHL
- Amazon Shipping, OnTrac, Canada Post
- Regional carriers worldwide
Setup and Dependencies
1. Maven Dependencies
<properties> <spring-boot.version>3.1.0</spring-boot.version> <okhttp.version>4.11.0</okhttp.version> <jackson.version>2.15.2</jackson.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- HTTP Client --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>${okhttp.version}</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>${jackson.version}</version> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <version>${spring-boot.version}</version> <scope>test</scope> </dependency> </dependencies> 2. ShipStation Configuration
@Configuration @ConfigurationProperties(prefix = "shipstation") @Data public class ShipStationConfig { private String apiKey; private String apiSecret; private String baseUrl = "https://ssapi.shipstation.com"; private String partnerKey; private int timeoutSeconds = 30; private int maxRetries = 3; private long retryDelayMs = 1000; @PostConstruct public void validateConfig() { if (apiKey == null || apiKey.trim().isEmpty()) { throw new IllegalStateException("ShipStation API key is required"); } if (apiSecret == null || apiSecret.trim().isEmpty()) { throw new IllegalStateException("ShipStation API secret is required"); } } public String getBasicAuthHeader() { String credentials = apiKey + ":" + apiSecret; return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes()); } } @Configuration public class ObjectMapperConfig { @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); return mapper; } } # application.yml shipstation: api-key: ${SHIPSTATION_API_KEY:your_api_key} api-secret: ${SHIPSTATION_API_SECRET:your_api_secret} base-url: https://ssapi.shipstation.com timeout-seconds: 30 max-retries: 3 retry-delay-ms: 1000 Core Implementation
1. HTTP Client Service
@Component @Slf4j public class ShipStationHttpClient { private final ShipStationConfig config; private final ObjectMapper objectMapper; private final OkHttpClient httpClient; public ShipStationHttpClient(ShipStationConfig config, ObjectMapper objectMapper) { this.config = config; this.objectMapper = objectMapper; this.httpClient = new OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .readTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .writeTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .addInterceptor(new RetryInterceptor(config.getMaxRetries(), config.getRetryDelayMs())) .addInterceptor(new AuthInterceptor(config.getBasicAuthHeader())) .addInterceptor(new LoggingInterceptor()) .build(); } public <T> T get(String endpoint, Class<T> responseType) { Request request = new Request.Builder() .url(config.getBaseUrl() + endpoint) .get() .build(); return executeRequest(request, responseType); } public <T> T post(String endpoint, Object requestBody, Class<T> responseType) { try { String jsonBody = objectMapper.writeValueAsString(requestBody); RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json")); Request request = new Request.Builder() .url(config.getBaseUrl() + endpoint) .post(body) .build(); return executeRequest(request, responseType); } catch (JsonProcessingException e) { throw new ShipStationException("Failed to serialize request body", e); } } public <T> T put(String endpoint, Object requestBody, Class<T> responseType) { try { String jsonBody = objectMapper.writeValueAsString(requestBody); RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json")); Request request = new Request.Builder() .url(config.getBaseUrl() + endpoint) .put(body) .build(); return executeRequest(request, responseType); } catch (JsonProcessingException e) { throw new ShipStationException("Failed to serialize request body", e); } } public void delete(String endpoint) { Request request = new Request.Builder() .url(config.getBaseUrl() + endpoint) .delete() .build(); executeRequest(request, Void.class); } private <T> T executeRequest(Request request, Class<T> responseType) { try (Response response = httpClient.newCall(request).execute()) { String responseBody = response.body() != null ? response.body().string() : null; if (!response.isSuccessful()) { handleErrorResponse(response, responseBody); } if (responseType == Void.class || responseBody == null || responseBody.trim().isEmpty()) { return null; } return objectMapper.readValue(responseBody, responseType); } catch (IOException e) { throw new ShipStationException("HTTP request failed: " + e.getMessage(), e); } } private void handleErrorResponse(Response response, String responseBody) { try { if (responseBody != null) { ShipStationError error = objectMapper.readValue(responseBody, ShipStationError.class); throw new ShipStationApiException(error.getMessage(), response.code(), error); } else { throw new ShipStationException("HTTP error: " + response.code() + " - " + response.message()); } } catch (JsonProcessingException e) { throw new ShipStationException("Failed to parse error response: " + responseBody, e); } } // Interceptor classes private static class AuthInterceptor implements Interceptor { private final String authHeader; public AuthInterceptor(String authHeader) { this.authHeader = authHeader; } @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request().newBuilder() .addHeader("Authorization", authHeader) .addHeader("Content-Type", "application/json") .build(); return chain.proceed(request); } } private static class RetryInterceptor implements Interceptor { private final int maxRetries; private final long retryDelayMs; public RetryInterceptor(int maxRetries, long retryDelayMs) { this.maxRetries = maxRetries; this.retryDelayMs = retryDelayMs; } @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); Response response = null; IOException exception = null; for (int attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { try { Thread.sleep(retryDelayMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ShipStationException("Request interrupted", e); } } try { response = chain.proceed(request); if (response.isSuccessful() || !isRetryable(response.code())) { return response; } response.close(); } catch (IOException e) { exception = e; } } if (response != null) { throw new ShipStationException("Request failed after " + maxRetries + " retries. Last status: " + response.code()); } else { throw new ShipStationException("Request failed after " + maxRetries + " retries", exception); } } private boolean isRetryable(int statusCode) { return statusCode == 429 || // Too Many Requests statusCode >= 500; // Server errors } } private static class LoggingInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); long startTime = System.currentTimeMillis(); log.debug("--> {} {}", request.method(), request.url()); Response response = chain.proceed(request); long duration = System.currentTimeMillis() - startTime; log.debug("<-- {} {} ({}ms)", response.code(), response.message(), duration); return response; } } } 2. Domain Models
// Common Response Wrapper @Data public class ShipStationResponse<T> { private Integer page; private Integer pages; private Integer total; private List<T> data; } // Error Response @Data public class ShipStationError { private String message; private String error; private String code; } // Custom Exceptions public class ShipStationException extends RuntimeException { public ShipStationException(String message) { super(message); } public ShipStationException(String message, Throwable cause) { super(message, cause); } } public class ShipStationApiException extends ShipStationException { private final int statusCode; private final ShipStationError error; public ShipStationApiException(String message, int statusCode, ShipStationError error) { super(message); this.statusCode = statusCode; this.error = error; } public int getStatusCode() { return statusCode; } public ShipStationError getError() { return error; } } Order Management
1. Order Models
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Order { private Long orderId; private String orderNumber; private String orderKey; private LocalDateTime orderDate; private LocalDateTime createDate; private LocalDateTime modifyDate; private String orderStatus; private String customerUsername; private String customerEmail; private BigDecimal orderTotal; private BigDecimal amountPaid; private BigDecimal taxAmount; private BigDecimal shippingAmount; private String customerNotes; private String internalNotes; private Boolean gift; private String giftMessage; private String paymentMethod; private LocalDateTime paymentDate; private BigDecimal adjustment; private String adjustmentReason; private String requestedShippingService; private String carrierCode; private String serviceCode; private String packageCode; private String confirmation; private LocalDateTime shipDate; private Boolean holdUntilDate; private Weight weight; private Dimensions dimensions; private InsuranceOptions insuranceOptions; private InternationalOptions internationalOptions; private AdvancedOptions advancedOptions; private List<Tag> tagIds; private String userId; private Boolean externallyFulfilled; private String externallyFulfilledBy; private String externallyFulfilledById; private String externallyFulfilledData; private String labelMessages; private List<OrderItem> items; private List<Fee> fees; // Addresses private Address billTo; private Address shipTo; // Embedded objects @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class Weight { private BigDecimal value; private String units; // pounds, ounces, grams } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class Dimensions { private String units; // inches, centimeters private BigDecimal length; private BigDecimal width; private BigDecimal height; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class InsuranceOptions { private String provider; private Boolean insureShipment; private BigDecimal insuredValue; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class InternationalOptions { private String contents; private String customsItems; private String nonDelivery; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class AdvancedOptions { private Long warehouseId; private Boolean nonMachinable; private Boolean saturdayDelivery; private Boolean containsAlcohol; private Boolean mergedOrSplit; private List<Long> mergedIds; private Long parentId; private String storeId; private String customField1; private String customField2; private String customField3; private String source; private String billToParty; private String billToAccount; private String billToPostalCode; private String billToCountryCode; private String billToMyOtherAccount; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class OrderItem { private Long orderItemId; private String lineItemKey; private String sku; private String name; private String imageUrl; private Weight weight; private Integer quantity; private BigDecimal unitPrice; private BigDecimal taxAmount; private BigDecimal shippingAmount; private String warehouseLocation; private Integer productId; private String fulfillmentSku; private Boolean adjustment; private String upc; private String createDate; private String modifyDate; private Map<String, Object> options; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Address { private String name; private String company; private String street1; private String street2; private String street3; private String city; private String state; private String postalCode; private String country; private String phone; private Boolean residential; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Tag { private Long tagId; private String name; private String color; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Fee { private String name; private BigDecimal amount; } // Order Request DTOs @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateOrderRequest { @NotBlank private String orderNumber; @NotBlank private String orderStatus; @NotNull private LocalDateTime orderDate; @NotNull private Address shipTo; @NotEmpty private List<OrderItem> items; private Address billTo; private BigDecimal orderTotal; private BigDecimal taxAmount; private BigDecimal shippingAmount; private String customerEmail; private String customerNotes; private String internalNotes; private AdvancedOptions advancedOptions; private List<Tag> tagIds; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class UpdateOrderRequest { private String orderStatus; private Address shipTo; private List<OrderItem> items; private BigDecimal orderTotal; private BigDecimal taxAmount; private BigDecimal shippingAmount; private String customerNotes; private String internalNotes; private AdvancedOptions advancedOptions; private List<Tag> tagIds; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class OrderQuery { private String customerName; private String itemKeyword; private LocalDateTime createDateStart; private LocalDateTime createDateEnd; private LocalDateTime modifyDateStart; private LocalDateTime modifyDateEnd; private LocalDateTime orderDateStart; private LocalDateTime orderDateEnd; private String orderNumber; private String orderStatus; private String paymentDateStart; private String paymentDateEnd; private String storeId; private String sortBy; private String sortDir; private Integer page; private Integer pageSize; } 2. Order Service
public interface OrderService { Order createOrder(CreateOrderRequest request); Order getOrder(Long orderId); Order getOrderByNumber(String orderNumber); Order updateOrder(Long orderId, UpdateOrderRequest request); void deleteOrder(Long orderId); void addTagToOrder(Long orderId, Long tagId); void removeTagFromOrder(Long orderId, Long tagId); ShipStationResponse<Order> listOrders(OrderQuery query); void assignUserToOrder(Long orderId, String userId); List<Order> getOrdersByStatus(String status); Order markOrderAsShipped(Long orderId, MarkAsShippedRequest request); } @Service @Slf4j public class ShipStationOrderService implements OrderService { private final ShipStationHttpClient httpClient; private final ObjectMapper objectMapper; public ShipStationOrderService(ShipStationHttpClient httpClient, ObjectMapper objectMapper) { this.httpClient = httpClient; this.objectMapper = objectMapper; } @Override public Order createOrder(CreateOrderRequest request) { log.info("Creating order: {}", request.getOrderNumber()); Order order = httpClient.post("/orders", request, Order.class); log.info("Order created successfully: {} (ID: {})", order.getOrderNumber(), order.getOrderId()); return order; } @Override public Order getOrder(Long orderId) { log.debug("Fetching order: {}", orderId); Order order = httpClient.get("/orders/" + orderId, Order.class); log.debug("Order fetched successfully: {}", orderId); return order; } @Override public Order getOrderByNumber(String orderNumber) { log.debug("Fetching order by number: {}", orderNumber); OrderQuery query = OrderQuery.builder() .orderNumber(orderNumber) .pageSize(1) .build(); ShipStationResponse<Order> response = listOrders(query); if (response.getData() == null || response.getData().isEmpty()) { throw new OrderNotFoundException("Order not found with number: " + orderNumber); } return response.getData().get(0); } @Override public Order updateOrder(Long orderId, UpdateOrderRequest request) { log.info("Updating order: {}", orderId); Order order = httpClient.put("/orders/" + orderId, request, Order.class); log.info("Order updated successfully: {}", orderId); return order; } @Override public void deleteOrder(Long orderId) { log.info("Deleting order: {}", orderId); httpClient.delete("/orders/" + orderId); log.info("Order deleted successfully: {}", orderId); } @Override public void addTagToOrder(Long orderId, Long tagId) { log.debug("Adding tag {} to order: {}", tagId, orderId); Map<String, Object> request = Map.of("tagId", tagId); httpClient.post("/orders/addtag", request, Void.class); log.debug("Tag added successfully to order: {}", orderId); } @Override public void removeTagFromOrder(Long orderId, Long tagId) { log.debug("Removing tag {} from order: {}", tagId, orderId); Map<String, Object> request = Map.of("tagId", tagId); httpClient.post("/orders/removetag", request, Void.class); log.debug("Tag removed successfully from order: {}", orderId); } @Override public ShipStationResponse<Order> listOrders(OrderQuery query) { log.debug("Listing orders with query: {}", query); String queryString = buildQueryString(query); ShipStationResponse<Order> response = httpClient.get("/orders" + queryString, objectMapper.getTypeFactory().constructParametricType(ShipStationResponse.class, Order.class)); log.debug("Found {} orders", response.getData() != null ? response.getData().size() : 0); return response; } @Override public void assignUserToOrder(Long orderId, String userId) { log.info("Assigning user {} to order: {}", userId, orderId); Map<String, Object> request = Map.of("userId", userId); httpClient.post("/orders/assignUser", request, Void.class); log.info("User assigned successfully to order: {}", orderId); } @Override public List<Order> getOrdersByStatus(String status) { log.debug("Fetching orders with status: {}", status); OrderQuery query = OrderQuery.builder() .orderStatus(status) .pageSize(100) // Maximum page size .build(); ShipStationResponse<Order> response = listOrders(query); return response.getData() != null ? response.getData() : new ArrayList<>(); } @Override public Order markOrderAsShipped(Long orderId, MarkAsShippedRequest request) { log.info("Marking order as shipped: {}", orderId); Order order = httpClient.post("/orders/markasshipped", request, Order.class); log.info("Order marked as shipped successfully: {}", orderId); return order; } private String buildQueryString(OrderQuery query) { if (query == null) { return ""; } Map<String, Object> params = new HashMap<>(); if (query.getCustomerName() != null) params.put("customerName", query.getCustomerName()); if (query.getItemKeyword() != null) params.put("itemKeyword", query.getItemKeyword()); if (query.getCreateDateStart() != null) params.put("createDateStart", formatDate(query.getCreateDateStart())); if (query.getCreateDateEnd() != null) params.put("createDateEnd", formatDate(query.getCreateDateEnd())); if (query.getModifyDateStart() != null) params.put("modifyDateStart", formatDate(query.getModifyDateStart())); if (query.getModifyDateEnd() != null) params.put("modifyDateEnd", formatDate(query.getModifyDateEnd())); if (query.getOrderDateStart() != null) params.put("orderDateStart", formatDate(query.getOrderDateStart())); if (query.getOrderDateEnd() != null) params.put("orderDateEnd", formatDate(query.getOrderDateEnd())); if (query.getOrderNumber() != null) params.put("orderNumber", query.getOrderNumber()); if (query.getOrderStatus() != null) params.put("orderStatus", query.getOrderStatus()); if (query.getStoreId() != null) params.put("storeId", query.getStoreId()); if (query.getSortBy() != null) params.put("sortBy", query.getSortBy()); if (query.getSortDir() != null) params.put("sortDir", query.getSortDir()); if (query.getPage() != null) params.put("page", query.getPage()); if (query.getPageSize() != null) params.put("pageSize", query.getPageSize()); if (params.isEmpty()) { return ""; } return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + encodeValue(entry.getValue())) .collect(Collectors.joining("&")); } private String formatDate(LocalDateTime dateTime) { return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); } private String encodeValue(Object value) { try { return URLEncoder.encode(value.toString(), StandardCharsets.UTF_8); } catch (Exception e) { return value.toString(); } } } Shipping and Labels
1. Shipping Models
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Shipment { private Long shipmentId; private Long orderId; private String orderKey; private String userId; private String customerEmail; private String orderNumber; private LocalDateTime createDate; private LocalDateTime shipDate; private BigDecimal shipmentCost; private BigDecimal insuranceCost; private String trackingNumber; private Boolean isReturnLabel; private String batchNumber; private String carrierCode; private String serviceCode; private String packageCode; private String confirmation; private Long warehouseId; private Boolean voided; private LocalDateTime voidDate; private Boolean marketplaceNotified; private String notifyErrorMessage; private String shipTo; private Weight weight; private Dimensions dimensions; private InsuranceOptions insuranceOptions; private AdvancedOptions advancedOptions; private List<ShipmentItem> shipmentItems; private LabelData labelData; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ShipmentItem { private Long orderItemId; private String lineItemKey; private String sku; private String name; private String imageUrl; private Weight weight; private Integer quantity; private BigDecimal unitPrice; private BigDecimal adjustment; private String upc; private String createDate; private String modifyDate; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class LabelData { private String shipmentId; private String shipmentCost; private String insuranceCost; private String trackingNumber; private String labelDate; private String shipDate; private String voidDate; private String vendorId; private String status; private String shipTo; private String shipFrom; private String weight; private String dimensions; private String insurance; private String carrierCode; private String serviceCode; private String packageCode; private String confirmation; private String labelData; // Base64 encoded label private String formData; // Base64 encoded form data private String labelDataFormat; // PDF, ZPL, etc. private String internationalDocument; private List<LabelMessage> messages; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class LabelMessage { private String type; private String message; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateLabelRequest { @NotNull private Long orderId; private String carrierCode; private String serviceCode; private String packageCode; private String confirmation; private String shipDate; private Weight weight; private Dimensions dimensions; private InsuranceOptions insuranceOptions; private InternationalOptions internationalOptions; private AdvancedOptions advancedOptions; private Boolean testLabel; private String labelFormat; // pdf, zpl, png private String labelLayout; // 4x6, letter } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Rate { private String serviceName; private String serviceCode; private BigDecimal shipmentCost; private BigDecimal otherCost; private String carrierCode; private String carrierNickname; private String carrierFriendlyName; private String currency; private LocalDateTime deliveryDate; private String deliveryDays; private Boolean deliveryDateGuaranteed; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RateRequest { @NotNull private String carrierCode; @NotNull private String fromPostalCode; @NotNull private String toState; @NotNull private String toCountry; @NotNull private String toPostalCode; @NotNull private String toCity; @NotNull private Weight weight; private Dimensions dimensions; private String serviceCode; private String packageCode; private String confirmation; private Boolean residential; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class MarkAsShippedRequest { @NotNull private Long orderId; private String carrierCode; private String serviceCode; private String trackingNumber; private LocalDateTime shipDate; private Boolean notifyCustomer; private Boolean notifySalesChannel; } 2. Shipping Service
@Service @Slf4j public class ShippingService { private final ShipStationHttpClient httpClient; private final ObjectMapper objectMapper; public ShippingService(ShipStationHttpClient httpClient, ObjectMapper objectMapper) { this.httpClient = httpClient; this.objectMapper = objectMapper; } public Shipment createShippingLabel(CreateLabelRequest request) { log.info("Creating shipping label for order: {}", request.getOrderId()); Shipment shipment = httpClient.post("/orders/createlabelfororder", request, Shipment.class); log.info("Shipping label created successfully for order: {}", request.getOrderId()); return shipment; } public List<Rate> getShippingRates(RateRequest request) { log.debug("Getting shipping rates for carrier: {}", request.getCarrierCode()); List<Rate> rates = httpClient.post("/shipments/getrates", request, objectMapper.getTypeFactory().constructCollectionType(List.class, Rate.class)); log.debug("Found {} shipping rates for carrier: {}", rates.size(), request.getCarrierCode()); return rates; } public void voidLabel(Long shipmentId) { log.info("Voiding shipping label: {}", shipmentId); httpClient.post("/shipments/voidlabel", Map.of("shipmentId", shipmentId), Void.class); log.info("Shipping label voided successfully: {}", shipmentId); } public ShipStationResponse<Shipment> listShipments(ShipmentQuery query) { log.debug("Listing shipments with query: {}", query); String queryString = buildQueryString(query); ShipStationResponse<Shipment> response = httpClient.get("/shipments" + queryString, objectMapper.getTypeFactory().constructParametricType(ShipStationResponse.class, Shipment.class)); log.debug("Found {} shipments", response.getData() != null ? response.getData().size() : 0); return response; } public byte[] getLabelAsPdf(Long shipmentId) { log.debug("Fetching PDF label for shipment: {}", shipmentId); // First get the shipment with label data Shipment shipment = httpClient.get("/shipments/" + shipmentId, Shipment.class); if (shipment.getLabelData() == null || shipment.getLabelData().getLabelData() == null) { throw new ShipStationException("No label data available for shipment: " + shipmentId); } // Decode base64 label data String base64Data = shipment.getLabelData().getLabelData(); return Base64.getDecoder().decode(base64Data); } public Shipment createShipmentFromRate(Rate selectedRate, Long orderId) { log.info("Creating shipment from rate for order: {}", orderId); CreateLabelRequest request = CreateLabelRequest.builder() .orderId(orderId) .carrierCode(selectedRate.getCarrierCode()) .serviceCode(selectedRate.getServiceCode()) .shipDate(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)) .build(); return createShippingLabel(request); } private String buildQueryString(ShipmentQuery query) { if (query == null) { return ""; } Map<String, Object> params = new HashMap<>(); if (query.getShipmentDateStart() != null) params.put("shipmentDateStart", formatDate(query.getShipmentDateStart())); if (query.getShipmentDateEnd() != null) params.put("shipmentDateEnd", formatDate(query.getShipmentDateEnd())); if (query.getCreateDateStart() != null) params.put("createDateStart", formatDate(query.getCreateDateStart())); if (query.getCreateDateEnd() != null) params.put("createDateEnd", formatDate(query.getCreateDateEnd())); if (query.getStoreId() != null) params.put("storeId", query.getStoreId()); if (query.getOrderNumber() != null) params.put("orderNumber", query.getOrderNumber()); if (query.getOrderId() != null) params.put("orderId", query.getOrderId()); if (query.getCarrierCode() != null) params.put("carrierCode", query.getCarrierCode()); if (query.getServiceCode() != null) params.put("serviceCode", query.getServiceCode()); if (query.getTrackingNumber() != null) params.put("trackingNumber", query.getTrackingNumber()); if (query.getRecipientName() != null) params.put("recipientName", query.getRecipientName()); if (query.getPage() != null) params.put("page", query.getPage()); if (query.getPageSize() != null) params.put("pageSize", query.getPageSize()); if (params.isEmpty()) { return ""; } return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + encodeValue(entry.getValue())) .collect(Collectors.joining("&")); } private String formatDate(LocalDateTime dateTime) { return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE); } private String encodeValue(Object value) { try { return URLEncoder.encode(value.toString(), StandardCharsets.UTF_8); } catch (Exception e) { return value.toString(); } } } Webhook Handling
1. Webhook Models
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class Webhook { private Long webhookId; private String sellerId; private String storeId; private String hookType; private String messageFormat; private String url; private String name; private Boolean active; private LocalDateTime createDate; private LocalDateTime modifyDate; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class WebhookEvent { private String resource_url; private String resource_type; private Map<String, Object> data; public Long getOrderId() { if (data != null && data.containsKey("resource_id")) { Object resourceId = data.get("resource_id"); if (resourceId instanceof Number) { return ((Number) resourceId).longValue(); } else if (resourceId instanceof String) { try { return Long.parseLong((String) resourceId); } catch (NumberFormatException e) { return null; } } } return null; } public String getEventType() { return resource_type; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateWebhookRequest { @NotBlank private String hookType; @NotBlank private String url; private String name; private Boolean active = true; private String messageFormat = "json"; private String storeId; } // Webhook Types public enum WebhookType { ORDER_NOTIFY("ORDER_NOTIFY"), ITEM_ORDER_NOTIFY("ITEM_ORDER_NOTIFY"), SHIP_NOTIFY("SHIP_NOTIFY"), ITEM_SHIP_NOTIFY("ITEM_SHIP_NOTIFY"); private final String value; WebhookType(String value) { this.value = value; } public String getValue() { return value; } } 2. Webhook Service
@Service @Slf4j public class WebhookService { private final ShipStationHttpClient httpClient; private final OrderService orderService; public WebhookService(ShipStationHttpClient httpClient, OrderService orderService) { this.httpClient = httpClient; this.orderService = orderService; } public Webhook createWebhook(CreateWebhookRequest request) { log.info("Creating webhook: {} -> {}", request.getHookType(), request.getUrl()); Webhook webhook = httpClient.post("/webhooks", request, Webhook.class); log.info("Webhook created successfully: {}", webhook.getWebhookId()); return webhook; } public List<Webhook> listWebhooks() { log.debug("Listing all webhooks"); List<Webhook> webhooks = httpClient.get("/webhooks", objectMapper.getTypeFactory().constructCollectionType(List.class, Webhook.class)); log.debug("Found {} webhooks", webhooks.size()); return webhooks; } public void deleteWebhook(Long webhookId) { log.info("Deleting webhook: {}", webhookId); httpClient.delete("/webhooks/" + webhookId); log.info("Webhook deleted successfully: {}", webhookId); } public void processWebhookEvent(WebhookEvent event, String signature) { log.info("Processing webhook event: {} - {}", event.getEventType(), event.getResource_url()); // Verify webhook signature (implementation depends on your security requirements) if (!verifySignature(event, signature)) { log.warn("Invalid webhook signature for event: {}", event.getEventType()); throw new SecurityException("Invalid webhook signature"); } switch (event.getEventType()) { case "ORDER_NOTIFY": handleOrderNotify(event); break; case "SHIP_NOTIFY": handleShipNotify(event); break; case "ITEM_ORDER_NOTIFY": handleItemOrderNotify(event); break; case "ITEM_SHIP_NOTIFY": handleItemShipNotify(event); break; default: log.warn("Unhandled webhook event type: {}", event.getEventType()); } } private void handleOrderNotify(WebhookEvent event) { Long orderId = event.getOrderId(); if (orderId == null) { log.warn("No order ID found in ORDER_NOTIFY event"); return; } try { Order order = orderService.getOrder(orderId); log.info("Processing new/updated order: {} - {}", order.getOrderNumber(), order.getOrderStatus()); // Implement your business logic here // e.g., update your database, send notifications, etc. } catch (Exception e) { log.error("Failed to process ORDER_NOTIFY event for order: {}", orderId, e); } } private void handleShipNotify(WebhookEvent event) { Long orderId = event.getOrderId(); if (orderId == null) { log.warn("No order ID found in SHIP_NOTIFY event"); return; } try { Order order = orderService.getOrder(orderId); log.info("Processing shipment notification for order: {} - {}", order.getOrderNumber(), order.getOrderStatus()); // Implement your business logic here // e.g., update order status, send tracking emails, etc. } catch (Exception e) { log.error("Failed to process SHIP_NOTIFY event for order: {}", orderId, e); } } private void handleItemOrderNotify(WebhookEvent event) { log.debug("Processing ITEM_ORDER_NOTIFY event"); // Implement item-level order notifications } private void handleItemShipNotify(WebhookEvent event) { log.debug("Processing ITEM_SHIP_NOTIFY event"); // Implement item-level shipment notifications } private boolean verifySignature(WebhookEvent event, String signature) { // Implementation depends on your security requirements // ShipStation may provide signature verification return true; // Placeholder } } REST Controllers
1. Order Controller
@RestController @RequestMapping("/api/shipstation/orders") @Validated @Slf4j public class OrderController { private final OrderService orderService; private final ShippingService shippingService; public OrderController(OrderService orderService, ShippingService shippingService) { this.orderService = orderService; this.shippingService = shippingService; } @PostMapping public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) { Order order = orderService.createOrder(request); return ResponseEntity.status(HttpStatus.CREATED).body(order); } @GetMapping("/{orderId}") public ResponseEntity<Order> getOrder(@PathVariable Long orderId) { Order order = orderService.getOrder(orderId); return ResponseEntity.ok(order); } @GetMapping("/number/{orderNumber}") public ResponseEntity<Order> getOrderByNumber(@PathVariable String orderNumber) { Order order = orderService.getOrderByNumber(orderNumber); return ResponseEntity.ok(order); } @PutMapping("/{orderId}") public ResponseEntity<Order> updateOrder(@PathVariable Long orderId, @Valid @RequestBody UpdateOrderRequest request) { Order order = orderService.updateOrder(orderId, request); return ResponseEntity.ok(order); } @DeleteMapping("/{orderId}") public ResponseEntity<Void> deleteOrder(@PathVariable Long orderId) { orderService.deleteOrder(orderId); return ResponseEntity.noContent().build(); } @GetMapping public ResponseEntity<ShipStationResponse<Order>> listOrders(@Valid OrderQuery query) { ShipStationResponse<Order> response = orderService.listOrders(query); return ResponseEntity.ok(response); } @PostMapping("/{orderId}/tags/{tagId}") public ResponseEntity<Void> addTagToOrder(@PathVariable Long orderId, @PathVariable Long tagId) { orderService.addTagToOrder(orderId, tagId); return ResponseEntity.ok().build(); } @DeleteMapping("/{orderId}/tags/{tagId}") public ResponseEntity<Void> removeTagFromOrder(@PathVariable Long orderId, @PathVariable Long tagId) { orderService.removeTagFromOrder(orderId, tagId); return ResponseEntity.noContent().build(); } @PostMapping("/{orderId}/ship") public ResponseEntity<Shipment> createShippingLabel(@PathVariable Long orderId, @Valid @RequestBody CreateLabelRequest request) { request.setOrderId(orderId); Shipment shipment = shippingService.createShippingLabel(request); return ResponseEntity.ok(shipment); } @PostMapping("/{orderId}/mark-shipped") public ResponseEntity<Order> markOrderAsShipped(@PathVariable Long orderId, @Valid @RequestBody MarkAsShippedRequest request) { request.setOrderId(orderId); Order order = orderService.markOrderAsShipped(orderId, request); return ResponseEntity.ok(order); } } 2. Shipping Controller
@RestController @RequestMapping("/api/shipstation/shipping") @Validated @Slf4j public class ShippingController { private final ShippingService shippingService; public ShippingController(ShippingService shippingService) { this.shippingService = shippingService; } @PostMapping("/rates") public ResponseEntity<List<Rate>> getShippingRates(@Valid @RequestBody RateRequest request) { List<Rate> rates = shippingService.getShippingRates(request); return ResponseEntity.ok(rates); } @GetMapping("/shipments/{shipmentId}/label") public ResponseEntity<byte[]> getShippingLabel(@PathVariable Long shipmentId) { byte[] labelPdf = shippingService.getLabelAsPdf(shipmentId); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header("Content-Disposition", "attachment; filename=\"label-" + shipmentId + ".pdf\"") .body(labelPdf); } @PostMapping("/shipments/{shipmentId}/void") public ResponseEntity<Void> voidShippingLabel(@PathVariable Long shipmentId) { shippingService.voidLabel(shipmentId); return ResponseEntity.ok().build(); } @GetMapping("/shipments") public ResponseEntity<ShipStationResponse<Shipment>> listShipments(@Valid ShipmentQuery query) { ShipStationResponse<Shipment> response = shippingService.listShipments(query); return ResponseEntity.ok(response); } } 3. Webhook Controller
@RestController @RequestMapping("/api/shipstation/webhooks") @Slf4j public class WebhookController { private final WebhookService webhookService; public WebhookController(WebhookService webhookService) { this.webhookService = webhookService; } @PostMapping public ResponseEntity<Webhook> createWebhook(@Valid @RequestBody CreateWebhookRequest request) { Webhook webhook = webhookService.createWebhook(request); return ResponseEntity.status(HttpStatus.CREATED).body(webhook); } @GetMapping public ResponseEntity<List<Webhook>> listWebhooks() { List<Webhook> webhooks = webhookService.listWebhooks(); return ResponseEntity.ok(webhooks); } @DeleteMapping("/{webhookId}") public ResponseEntity<Void> deleteWebhook(@PathVariable Long webhookId) { webhookService.deleteWebhook(webhookId); return ResponseEntity.noContent().build(); } @PostMapping("/events") public ResponseEntity<String> handleWebhookEvent(@RequestBody WebhookEvent event, @RequestHeader(value = "X-ShipStation-Signature", required = false) String signature) { try { webhookService.processWebhookEvent(event, signature); return ResponseEntity.ok("Webhook processed successfully"); } catch (Exception e) { log.error("Webhook processing failed", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("Webhook processing failed: " + e.getMessage()); } } } Advanced Features
1. Batch Order Processing
@Service @Slf4j public class BatchOrderService { private final OrderService orderService; private final ShippingService shippingService; @Async public CompletableFuture<BatchOrderResult> processOrdersInBatch(List<CreateOrderRequest> orders) { List<Order> successfulOrders = new ArrayList<>(); List<FailedOrder> failedOrders = new ArrayList<>(); for (CreateOrderRequest orderRequest : orders) { try { Order order = orderService.createOrder(orderRequest); successfulOrders.add(order); log.info("Successfully created order: {}", order.getOrderNumber()); } catch (Exception e) { failedOrders.add(FailedOrder.builder() .orderRequest(orderRequest) .errorMessage(e.getMessage()) .build()); log.error("Failed to create order: {}", orderRequest.getOrderNumber(), e); } } return CompletableFuture.completedFuture( BatchOrderResult.builder() .totalProcessed(orders.size()) .successfulCount(successfulOrders.size()) .failedCount(failedOrders.size()) .successfulOrders(successfulOrders) .failedOrders(failedOrders) .completedAt(LocalDateTime.now()) .build() ); } @Async public CompletableFuture<BatchShippingResult> createLabelsInBatch(List<Long> orderIds) { List<Shipment> successfulShipments = new ArrayList<>(); List<FailedShipping> failedShipments = new ArrayList<>(); for (Long orderId : orderIds) { try { CreateLabelRequest labelRequest = CreateLabelRequest.builder() .orderId(orderId) .testLabel(true) // Use test mode for batch processing .build(); Shipment shipment = shippingService.createShippingLabel(labelRequest); successfulShipments.add(shipment); log.info("Successfully created label for order: {}", orderId); } catch (Exception e) { failedShipments.add(FailedShipping.builder() .orderId(orderId) .errorMessage(e.getMessage()) .build()); log.error("Failed to create label for order: {}", orderId, e); } } return CompletableFuture.completedFuture( BatchShippingResult.builder() .totalProcessed(orderIds.size()) .successfulCount(successfulShipments.size()) .failedCount(failedShipments.size()) .successfulShipments(successfulShipments) .failedShipments(failedShipments) .completedAt(LocalDateTime.now()) .build() ); } } 2. Rate Shopping Service
@Service @Slf4j public class RateShoppingService { private final ShippingService shippingService; public RateSelection findBestRate(RateRequest request, RateSelectionStrategy strategy) { List<Rate> allRates = shippingService.getShippingRates(request); if (allRates.isEmpty()) { throw new ShipStationException("No shipping rates available for the given request"); } Rate selectedRate = strategy.selectRate(allRates); return RateSelection.builder() .allRates(allRates) .selectedRate(selectedRate) .selectionReason(strategy.getSelectionReason()) .selectedAt(LocalDateTime.now()) .build(); } public RateSelection findCheapestRate(RateRequest request) { return findBestRate(request, new CheapestRateStrategy()); } public RateSelection findFastestRate(RateRequest request) { return findBestRate(request, new FastestRateStrategy()); } public RateSelection findBestValueRate(RateRequest request) { return findBestRate(request, new BestValueStrategy()); } } public interface RateSelectionStrategy { Rate selectRate(List<Rate> rates); String getSelectionReason(); } @Component @Slf4j public class CheapestRateStrategy implements RateSelectionStrategy { @Override public Rate selectRate(List<Rate> rates) { Rate cheapest = rates.stream() .min(Comparator.comparing(Rate::getShipmentCost)) .orElseThrow(() -> new ShipStationException("No rates available")); log.debug("Selected cheapest rate: {} - ${}", cheapest.getServiceName(), cheapest.getShipmentCost()); return cheapest; } @Override public String getSelectionReason() { return "Lowest cost"; } } @Component @Slf4j public class FastestRateStrategy implements RateSelectionStrategy { @Override public Rate selectRate(List<Rate> rates) { Rate fastest = rates.stream() .filter(rate -> rate.getDeliveryDays() != null) .min(Comparator.comparing(rate -> { try { return Integer.parseInt(rate.getDeliveryDays()); } catch (NumberFormatException e) { return Integer.MAX_VALUE; } })) .orElseThrow(() -> new ShipStationException("No rates with delivery days available")); log.debug("Selected fastest rate: {} - {} days", fastest.getServiceName(), fastest.getDeliveryDays()); return fastest; } @Override public String getSelectionReason() { return "Fastest delivery"; } } Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class) class ShipStationOrderServiceTest { @Mock private ShipStationHttpClient httpClient; @Mock private ObjectMapper objectMapper; @InjectMocks private ShipStationOrderService orderService; @Test void whenCreateOrder_thenSuccess() { // Given CreateOrderRequest request = CreateOrderRequest.builder() .orderNumber("TEST-001") .orderStatus("awaiting_shipment") .orderDate(LocalDateTime.now()) .shipTo(new Address()) .items(List.of(new OrderItem())) .build(); Order expectedOrder = new Order(); expectedOrder.setOrderId(123L); expectedOrder.setOrderNumber("TEST-001"); when(httpClient.post(eq("/orders"), any(), eq(Order.class))) .thenReturn(expectedOrder); // When Order result = orderService.createOrder(request); // Then assertNotNull(result); assertEquals(123L, result.getOrderId()); assertEquals("TEST-001", result.getOrderNumber()); verify(httpClient).post(eq("/orders"), any(), eq(Order.class)); } @Test void whenGetOrderNotFound_thenThrowException() { // Given when(httpClient.get(eq("/orders/999"), eq(Order.class))) .thenThrow(new ShipStationApiException("Order not found", 404, new ShipStationError())); // When & Then assertThrows(ShipStationApiException.class, () -> { orderService.getOrder(999L); }); } } 2. Integration Test
@SpringBootTest @TestPropertySource(properties = { "shipstation.api-key=test-key", "shipstation.api-secret=test-secret" }) class ShipStationIntegrationTest { @Autowired private OrderService orderService; @Test @Disabled("For actual integration testing with real credentials") void testRealApiIntegration() { // This test would require actual ShipStation credentials OrderQuery query = OrderQuery.builder() .pageSize(5) .build(); ShipStationResponse<Order> response = orderService.listOrders(query); assertNotNull(response); // Additional assertions based on actual API response } } Best Practices
- Error Handling: Comprehensive exception handling with retry logic
- Rate Limiting: Respect ShipStation API rate limits (40 requests per minute)
- Idempotency: Ensure operations can be safely retried
- Logging: Detailed logging for debugging and monitoring
- Validation: Strict input validation for all API requests
- Security: Secure handling of API credentials and webhook signatures
- Performance: Async processing for batch operations
- Monitoring: Track API usage and error rates
This ShipStation integration provides a complete solution for e-commerce businesses to manage their shipping operations programmatically, from order creation to label generation and tracking.
Java Observability, Logging Intelligence & AI-Driven Monitoring (APM, Tracing, Logs & Anomaly Detection)
https://macronepal.com/blog/beyond-metrics-observing-serverless-and-traditional-java-applications-with-thundra-apm/
Explains using Thundra APM to observe both serverless and traditional Java applications by combining tracing, metrics, and logs into a unified observability platform for faster debugging and performance insights.
https://macronepal.com/blog/dynatrace-oneagent-in-java-2/
Explains Dynatrace OneAgent for Java, which automatically instruments JVM applications to capture metrics, traces, and logs, enabling full-stack monitoring and root-cause analysis with minimal configuration.
https://macronepal.com/blog/lightstep-java-sdk-distributed-tracing-and-observability-implementation/
Explains Lightstep Java SDK for distributed tracing, helping developers track requests across microservices and identify latency issues using OpenTelemetry-based observability.
https://macronepal.com/blog/honeycomb-io-beeline-for-java-complete-guide-2/
Explains Honeycomb Beeline for Java, which provides high-cardinality observability and deep query capabilities to understand complex system behavior and debug distributed systems efficiently.
https://macronepal.com/blog/lumigo-for-serverless-in-java-complete-distributed-tracing-guide-2/
Explains Lumigo for Java serverless applications, offering automatic distributed tracing, log correlation, and error tracking to simplify debugging in cloud-native environments. (Lumigo Docs)
https://macronepal.com/blog/from-noise-to-signals-implementing-log-anomaly-detection-in-java-applications/
Explains how to detect anomalies in Java logs using behavioral patterns and machine learning techniques to separate meaningful incidents from noisy log data and improve incident response.
https://macronepal.com/blog/ai-powered-log-analysis-in-java-from-reactive-debugging-to-proactive-insights/
Explains AI-driven log analysis for Java applications, shifting from manual debugging to predictive insights that identify issues early and improve system reliability using intelligent log processing.
https://macronepal.com/blog/titliel-java-logging-best-practices/
Explains best practices for Java logging, focusing on structured logs, proper log levels, performance optimization, and ensuring logs are useful for debugging and observability systems.
https://macronepal.com/blog/seeking-a-loguru-for-java-the-quest-for-elegant-and-simple-logging/
Explains the search for simpler, more elegant logging frameworks in Java, comparing modern logging approaches that aim to reduce complexity while improving readability and developer experience.