diff --git a/CHANGELOG.md b/CHANGELOG.md
index 718e7f1a8..8c4f199b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- Removes the deprecated, unusable `addCreditCardToUser` function
- Stripe has disabled the ability to pass plain credit card details over the wire and now requires using [Stripe.js/Elements/Checkout](https://support.stripe.com/questions/card-tokenization-restrictions-using-publishable-keys). Follow the [Decentralized (EasyPost-Manage Billing) Guide](https://docs.easypost.com/guides/get-started-with-forge/easypost-managed-billing-guide#referralcustomer-billing-management) for more details on the new flow to use.
- Makes `referralCustomer.retrieveEasypostStripeApiKey` public to help facilitate adding credit cards using Stripe.js
+- Redacts the API key in the `Authorization` header passed to request and response hooks. Hooks now receive only the last four characters of the key (eg: `Bearer ****WXYZ`) instead of the full key; the outgoing request is unchanged
- Adds support for `FedexDefaultAccount`, `GofoWalletAccount`, `UpsDapAccount`, `UniUniWalletAccount`, and `VehoWalletAccount` by properly routing carrier-account create requests to the `/carrier_accounts/open` endpoint.
## v8.8.0 (2026-06-25)
diff --git a/pom.xml b/pom.xml
index bf5655851..607d5cb9e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -106,6 +106,8 @@
UTF-8
UTF-8
8
+
+
@@ -122,6 +124,8 @@
maven-surefire-plugin
3.1.0
+
+ @{argLine} -Dnet.bytebuddy.experimental=true
junit.jupiter.execution.order.random.seed=99
diff --git a/src/main/java/com/easypost/hooks/RequestHookResponses.java b/src/main/java/com/easypost/hooks/RequestHookResponses.java
index 966408ad2..b49103298 100644
--- a/src/main/java/com/easypost/hooks/RequestHookResponses.java
+++ b/src/main/java/com/easypost/hooks/RequestHookResponses.java
@@ -19,7 +19,7 @@ public class RequestHookResponses {
/**
* RequestHookResponses constructor.
*
- * @param headers The headers of the request.
+ * @param headers The headers of the request, with the API key redacted to its last four characters.
* @param method The HTTP method of the request.
* @param path The path of the request.
* @param requestBody The JSON object representing the request body.
diff --git a/src/main/java/com/easypost/hooks/ResponseHookResponses.java b/src/main/java/com/easypost/hooks/ResponseHookResponses.java
index 8c4e65a65..4c4e43648 100644
--- a/src/main/java/com/easypost/hooks/ResponseHookResponses.java
+++ b/src/main/java/com/easypost/hooks/ResponseHookResponses.java
@@ -20,7 +20,7 @@ public class ResponseHookResponses {
* ResponseHookResponses constructor.
*
* @param httpStatus The HTTP status code of the response.
- * @param headers The headers of the response.
+ * @param headers The headers of the request, with the API key redacted to its last four characters.
* @param method The HTTP method of the request.
* @param path The path of the request.
* @param responseBody The response body as a string.
diff --git a/src/main/java/com/easypost/http/Requestor.java b/src/main/java/com/easypost/http/Requestor.java
index 5c3592e7f..e6a08261d 100644
--- a/src/main/java/com/easypost/http/Requestor.java
+++ b/src/main/java/com/easypost/http/Requestor.java
@@ -60,6 +60,8 @@ public enum RequestMethod {
private static final String DNS_CACHE_TTL_PROPERTY_NAME = "networkaddress.cache.ttl";
private static final String CUSTOM_URL_STREAM_HANDLER_PROPERTY_NAME = "com.easypost.net.customURLStreamHandler";
+ private static final String API_KEY_REDACTION_MASK = "****";
+ private static final int API_KEY_VISIBLE_CHARACTERS = 4;
private static String urlEncodePair(final String key, final String value) throws UnsupportedEncodingException {
return String.format("%s=%s", URLEncoder.encode(key, Constants.Http.CHARSET),
@@ -88,6 +90,36 @@ private static Map generateHeaders(String apiKey) throws Missing
return headers;
}
+ /**
+ * Set the header passed to request and response hooks. This is a copy of the HTTP request header with the
+ * API key redacted to its last four characters, so hooks never receive the full API key.
+ *
+ * @param apiKey API of this HTTP request.
+ * @return HTTP header with the API key redacted.
+ * @throws MissingParameterError When the request fails.
+ */
+ private static Map generateHookHeaders(String apiKey) throws MissingParameterError {
+ Map headers = generateHeaders(apiKey);
+ headers.put("Authorization", String.format("Bearer %s", redactApiKey(apiKey)));
+
+ return headers;
+ }
+
+ /**
+ * Redact an API key so only its last four characters are visible.
+ *
+ * @param apiKey API key to redact.
+ * @return Redacted API key.
+ */
+ private static String redactApiKey(String apiKey) {
+ // Fully mask keys too short to hide most of their characters.
+ if (apiKey == null || apiKey.length() <= API_KEY_VISIBLE_CHARACTERS * 2) {
+ return API_KEY_REDACTION_MASK;
+ }
+
+ return API_KEY_REDACTION_MASK + apiKey.substring(apiKey.length() - API_KEY_VISIBLE_CHARACTERS);
+ }
+
/**
* Convert space to hyphen.
*
@@ -576,7 +608,7 @@ private static T httpRequest(final RequestMethod method, final String url, f
}
Instant requestTimestamp = Instant.now();
UUID requestUuid = UUID.randomUUID();
- Map headers = generateHeaders(client.getApiKey());
+ Map headers = generateHookHeaders(client.getApiKey());
RequestHookResponses requestResponse = new RequestHookResponses(headers, method.toString(), url, body,
requestTimestamp.toString(), requestUuid.toString());
diff --git a/src/test/java/com/easypost/HookTest.java b/src/test/java/com/easypost/HookTest.java
index 3f275750f..9ad9cd9dd 100644
--- a/src/test/java/com/easypost/HookTest.java
+++ b/src/test/java/com/easypost/HookTest.java
@@ -4,15 +4,26 @@
import com.easypost.exception.EasyPostException;
import com.easypost.hooks.RequestHookResponses;
import com.easypost.hooks.ResponseHookResponses;
+import com.easypost.service.EasyPostClient;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
+import javax.net.ssl.HttpsURLConnection;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
import java.util.function.Function;
public class HookTest {
@@ -30,6 +41,14 @@ public static void setup() throws EasyPostException {
vcr = new TestUtils.VCR("hook", TestUtils.ApiKey.TEST);
}
+ /**
+ * Clear the connection override after each test.
+ */
+ @AfterEach
+ public void tearDown() {
+ EasyPost._vcrUrlFunction = null;
+ }
+
/**
* Test failing a hook if we subscribed to a request hook.
*
@@ -111,6 +130,42 @@ public static Object testResponseHooks(ResponseHookResponses data) {
return true;
}
+ /**
+ * Build a mocked connection that returns a successful Address response.
+ *
+ * @return HttpsURLConnection object.
+ * @throws IOException if the mock cannot be set up.
+ */
+ private static HttpsURLConnection mockConnection() throws IOException {
+ byte[] body = "{\"id\": \"adr_123\", \"object\": \"Address\"}".getBytes(StandardCharsets.UTF_8);
+ HttpsURLConnection connection = Mockito.mock(HttpsURLConnection.class);
+ Mockito.when(connection.getResponseCode()).thenReturn(200);
+ Mockito.when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(body));
+ return connection;
+ }
+
+ /**
+ * Make a request over a mocked connection and capture the headers passed to the request and response hooks.
+ *
+ * @param apiKey The API key to make the request with.
+ * @param connection The mocked connection to send the request over.
+ * @return The headers passed to the request hook, followed by the headers passed to the response hook.
+ * @throws EasyPostException when the request fails.
+ */
+ private static List