diff --git a/riskified-sdk/src/main/java/com/riskified/JSONFormater.java b/riskified-sdk/src/main/java/com/riskified/JSONFormater.java
index 58fbbc56..92bd0fb8 100644
--- a/riskified-sdk/src/main/java/com/riskified/JSONFormater.java
+++ b/riskified-sdk/src/main/java/com/riskified/JSONFormater.java
@@ -2,10 +2,11 @@
import java.lang.reflect.Type;
import java.text.DateFormat;
-import java.text.SimpleDateFormat;
import java.util.Date;
import com.google.gson.*;
+import com.riskified.adapters.NaiveDateTypeAdapter;
+import com.riskified.adapters.PaymentDetailsAdapterFactory;
import com.riskified.models.BankWirePaymentDetails;
import com.riskified.models.CreditCardPaymentDetails;
import com.riskified.models.IPaymentDetails;
@@ -18,17 +19,60 @@ public class JSONFormater {
public static String toJson(Object obj) {
Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Date.class, new DateTimeSerializer())
- .registerTypeAdapterFactory(paymentDetailsSerializer())
+ .registerTypeAdapterFactory(new PaymentDetailsAdapterFactory())
.create();
return gson.toJson(obj);
}
+ /**
+ * Serializer for the 25 offset-bearing date fields of the wire contract — every
+ * {@link Date} that is not explicitly annotated with
+ * {@code @JsonAdapter(NaiveDateTypeAdapter.class)}.
+ *
+ *
+ * Two properties matter here:
+ *
+ *
UTC, not the JVM default timezone. The previous implementation used the default
+ * timezone, so the same {@link Date} serialized differently on two machines — and since the
+ * HMAC is computed over the serialized bytes, so did the signature.
+ *
The offset is written as {@code +00:00}, not {@code Z}. Both are valid ISO 8601 and
+ * denote the same instant, but the reference implementation renders a zero-offset
+ * {@code DateTimeOffset} as {@code +00:00}, and byte parity with the reference is the point.
+ * The literal is safe because the formatter is pinned to UTC one line below.
+ *
+ *
+ * @see NaiveDateTypeAdapter for the 13 fields that must carry no offset at all
+ */
public static class DateTimeSerializer implements JsonSerializer {
+ /** Zero offset spelled out, to match how .NET renders a UTC {@code DateTimeOffset}. */
+ public static final String OFFSET_PATTERN = "yyyy-MM-dd'T'HH:mm:ss'+00:00'";
+
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
- DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
+ DateFormat df = NaiveDateTypeAdapter.utcFormat(OFFSET_PATTERN);
return new JsonPrimitive(df.format(src));
}
}
+
+ /**
+ * The polymorphic {@code payment_details} adapter that this SDK used to register.
+ *
+ *
+ * No longer used, and deliberately so. It injected a {@code "method"} key into every
+ * {@code payment_details} object with values ({@code credit_card}, {@code bank_wire},
+ * {@code digital_wallet}) that disagreed with the {@code payment_type} value emitted alongside
+ * it. The wire contract carries no type discriminator on {@code payment_details}: the
+ * variant is expressed by which keys are present, with the constant {@code payment_type} acting
+ * as the de-facto discriminator. No other SDK in the fleet emitted {@code method}.
+ *
+ *
+ * Gson dispatches on the runtime type of each element of a {@code List} on its
+ * own, so nothing is lost by dropping the factory. It is retained here only so that callers who
+ * built their own {@code Gson} against it still compile; do not register it.
+ *
+ * @return the legacy discriminator-injecting factory
+ * @deprecated the {@code method} key it emits is not part of the Riskified wire contract.
+ */
+ @Deprecated
public static RuntimeTypeAdapterFactory paymentDetailsSerializer() {
return RuntimeTypeAdapterFactory
.of(IPaymentDetails.class, "method")
@@ -38,5 +82,5 @@ public static RuntimeTypeAdapterFactory paymentDetailsSerializer() {
.registerSubtype(BankWirePaymentDetails.class, "bank_wire")
.registerSubtype(WalletPaymentDetails.class, "digital_wallet");
}
-
+
}
diff --git a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java
index d3814c27..41619d41 100644
--- a/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java
+++ b/riskified-sdk/src/main/java/com/riskified/RiskifiedClient.java
@@ -5,6 +5,8 @@
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.riskified.models.*;
+// Explicit: com.riskified.models.Error would otherwise be ambiguous with java.lang.Error.
+import com.riskified.models.Error;
import com.riskified.validations.FieldBadFormatException;
import com.riskified.validations.IValidated;
import com.riskified.validations.Validation;
@@ -901,22 +903,60 @@ private Response postCheckoutOrder(Object data, String url) throws IOException,
response = executeClient(client, request);
String postBody = EntityUtils.toString(response.getEntity(), "UTF-8");
int status = response.getStatusLine().getStatusCode();
- Response responseObject = getCheckoutResponseObject(postBody);
- switch (status) {
- case 200:
- return responseObject;
- case 400:
- throw new HttpResponseException(status, responseObject.getError().getMessage());
- case 401:
- throw new HttpResponseException(status, responseObject.getError().getMessage());
- case 404:
- throw new HttpResponseException(status, responseObject.getError().getMessage());
- case 429:
- throw new HttpResponseException(status, responseObject.getError().getMessage());
- case 504:
- throw new HttpResponseException(status, "Temporary error, please retry");
- default:
- throw new HttpResponseException(500, "Contact Riskified support");
+ String statusText = response.getStatusLine().getReasonPhrase();
+ if (status == 200) {
+ return getCheckoutResponseObject(postBody);
+ }
+ // Parsing happens only after the 200 check, and never decides whether the call failed.
+ // Previously the body was parsed first and dereferenced unguarded, so any error body that
+ // was not the {"error":{"message":...}} shape raised a NullPointerException in place of the
+ // HTTP error the caller was waiting for.
+ throw buildHttpException(status, statusText, postBody, tryParseCheckoutResponse(postBody));
+ }
+
+ /**
+ * Turns a non-2xx response into an exception that keeps the real status, the raw body and the
+ * parsed error, whatever shape the body turned out to be.
+ *
+ *
+ * There is deliberately no {@code default} case rewriting the status: an unmatched status is
+ * reported as itself. Collapsing 502, 503 and 500 into one substituted 500 with the body
+ * discarded is what used to make retry decisions impossible.
+ */
+ static RiskifiedHttpException buildHttpException(int status, String statusText, String body, Response parsed) {
+ Error error = parsed == null ? null : parsed.getError();
+ String message;
+ if (status == 504) {
+ // Documented by the CBG and Account Secure specs with no content schema.
+ message = "Temporary error, please retry";
+ } else if (error != null && error.getMessage() != null && !error.getMessage().isEmpty()) {
+ message = error.getMessage();
+ } else if (body != null && !body.trim().isEmpty()) {
+ message = body;
+ } else if (statusText != null && !statusText.isEmpty()) {
+ message = statusText;
+ } else {
+ message = "HTTP " + status;
+ }
+ return new RiskifiedHttpException(status, statusText, body, error, message);
+ }
+
+ /**
+ * Best-effort parse of an error body into the common {@code {"error":{"message":...}}} shape.
+ * Returns {@code null} for any of the other six documented shapes, for a bare JSON string, and
+ * for an empty or malformed body. Never throws: the caller already knows the request failed and
+ * the raw body is preserved regardless.
+ */
+ private static Response tryParseCheckoutResponse(String postBody) {
+ try {
+ CheckoutResponse res = new Gson().fromJson(postBody, CheckoutResponse.class);
+ if (res == null) {
+ return null;
+ }
+ res.setOrder(res.getCheckout());
+ return res;
+ } catch (RuntimeException e) {
+ return null;
}
}
@@ -1014,26 +1054,20 @@ private Response postOrder(Object data, String url) throws IOException {
HttpResponse response;
HttpClient client = constructHttpClient();
response = executeClient(client, request);
- String postBody = EntityUtils.toString(response.getEntity());
+ // Explicit UTF-8. Without it EntityUtils falls back to ISO-8859-1 whenever the response
+ // omits a charset parameter, which turns any non-ASCII error message into mojibake --
+ // and this body is now preserved verbatim on the exception, so mangling it here would
+ // corrupt the thing the caller is meant to read. postCheckoutOrder already did this.
+ String postBody = EntityUtils.toString(response.getEntity(), "UTF-8");
int status = response.getStatusLine().getStatusCode();
- Response responseObject = getResponseObject(postBody);
- switch (status) {
- case 200:
- return responseObject;
- case 400:
- throw new HttpResponseException(status, postBody);
- case 401:
- throw new HttpResponseException(status, postBody);
- case 404:
- throw new HttpResponseException(status, postBody);
- case 429:
- throw new HttpResponseException(status, postBody);
- case 504:
- throw new HttpResponseException(status, "Temporary error, please retry");
- default:
- throw new HttpResponseException(500, "Contact Riskified support");
- }
+ String statusText = response.getStatusLine().getReasonPhrase();
+
+ if (status == 200) {
+ return getResponseObject(postBody);
+ }
+ // Same rule as the checkout path: report the status the server sent, keep the body.
+ throw buildHttpException(status, statusText, postBody, getResponseObject(postBody));
}
private Response getResponseObject(String postBody) throws IOException {
diff --git a/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java b/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java
new file mode 100644
index 00000000..284c02b3
--- /dev/null
+++ b/riskified-sdk/src/main/java/com/riskified/RiskifiedHttpException.java
@@ -0,0 +1,78 @@
+package com.riskified;
+
+import com.riskified.models.Error;
+import org.apache.http.client.HttpResponseException;
+
+/**
+ * A non-2xx response from the Riskified API, with the transport facts preserved as structured
+ * fields rather than flattened into a message string.
+ *
+ *
+ * It extends {@link HttpResponseException}, so every existing {@code catch (HttpResponseException)}
+ * keeps working and {@link #getStatusCode()} keeps returning what it always did. What is new is
+ * that the real status now survives: the client used to rewrite every unmatched status to
+ * {@code 500 "Contact Riskified support"} and discard the body, which made a 502, a 503 and a
+ * genuine 500 indistinguishable to a caller trying to decide whether to retry.
+ *
+ *
+ * {@link #getResponseBody()} carries the raw body as a separate field so that logging can redact
+ * it. Response bodies can contain customer PII, and a message string is the one place logging
+ * cannot reach — a defect the reference implementation has
+ * ({@code Riskified.SDK/Utils/HttpUtils.cs:224}) and that this SDK should not deepen.
+ *
+ *
+ * The API returns seven mutually incompatible error body shapes across its six specs
+ * ({@code docs/flows/00-shared-contract.md} section 6). {@link #getError()} is populated only when
+ * the body matched the common {@code {"error": {"message": ...}}} shape; for the other six it is
+ * {@code null} and the body is still available verbatim. It is never a reason to fail.
+ *
+ * @since 6.4.1
+ */
+public class RiskifiedHttpException extends HttpResponseException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String statusText;
+ private final String responseBody;
+ private final Error error;
+
+ /**
+ * @param statusCode the real HTTP status code, never a substituted one
+ * @param statusText the HTTP reason phrase, may be {@code null}
+ * @param responseBody the raw response body, may be {@code null}
+ * @param error the parsed error object when the body matched the common shape, else
+ * {@code null}
+ * @param message the exception message
+ */
+ public RiskifiedHttpException(int statusCode, String statusText, String responseBody, Error error,
+ String message) {
+ super(statusCode, message);
+ this.statusText = statusText;
+ this.responseBody = responseBody;
+ this.error = error;
+ }
+
+ /**
+ * @return the HTTP reason phrase, or {@code null} if the response carried none
+ */
+ public String getStatusText() {
+ return statusText;
+ }
+
+ /**
+ * @return the raw response body, or {@code null} if the response had none. Attach this to a
+ * structured log field, not to a message string — it can contain customer PII.
+ */
+ public String getResponseBody() {
+ return responseBody;
+ }
+
+ /**
+ * @return the parsed error, or {@code null} when the body did not match the common
+ * {@code {"error": {"message": ...}}} shape. A {@code null} here says nothing about
+ * whether the request failed.
+ */
+ public Error getError() {
+ return error;
+ }
+}
diff --git a/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java b/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java
index 7b75d576..5d00b3fd 100644
--- a/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java
+++ b/riskified-sdk/src/main/java/com/riskified/SHA256Handler.java
@@ -5,6 +5,7 @@
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
import java.util.Formatter;
public class SHA256Handler {
@@ -21,7 +22,10 @@ public synchronized String createSHA256(byte[] data) throws IllegalStateExceptio
}
private Mac createSHA256Key(String authKey) throws RiskifiedError {
- Key sk = new SecretKeySpec(authKey.getBytes(), "HmacSHA256");
+ // UTF-8, never the platform default charset: the default makes the signature depend on
+ // a JVM locale setting. Riskified tokens are hex, so UTF-8 and the reference
+ // implementation's ASCII (Riskified.SDK/Utils/HttpUtils.cs:152) agree byte for byte.
+ Key sk = new SecretKeySpec(authKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Mac mac;
try {
mac = Mac.getInstance(sk.getAlgorithm());
diff --git a/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java b/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java
new file mode 100644
index 00000000..38734795
--- /dev/null
+++ b/riskified-sdk/src/main/java/com/riskified/adapters/NaiveDateTypeAdapter.java
@@ -0,0 +1,129 @@
+package com.riskified.adapters;
+
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Gson {@link TypeAdapter} for the naive (offset-free) date fields of the Riskified wire
+ * contract.
+ *
+ *
+ * The contract carries two date formats and the split between them is per field, not per type:
+ *
+ *
25 offset-bearing fields — order-level and money-related timestamps — serialize as
+ * {@code 2026-08-13T10:00:00+00:00}. These are handled by the {@code Date} adapter registered
+ * globally in {@code JSONFormater}.
+ *
13 naive fields — line-item, travel and passenger dates — serialize as
+ * {@code 2026-08-13T10:00:00}, with no offset. Those thirteen fields carry
+ * {@code @JsonAdapter(NaiveDateTypeAdapter.class)}, which takes precedence over the globally
+ * registered adapter.
+ *
+ *
+ *
+ * The reference implementation reaches the same split by accident, through two CLR types
+ * ({@code DateTimeOffset?} vs {@code DateTime?}) and no configured converter. Java has a single
+ * {@link Date} type, so the split has to be declared field by field instead. The authoritative
+ * lists live in the contract corpus at {@code docs/flows/01-model-catalog.md} section 3.
+ *
+ *
+ * Both formats are rendered in UTC. Rendering in the JVM default timezone would make the
+ * same {@link Date} serialize differently on two machines, and the HMAC is computed over the
+ * serialized bytes.
+ *
+ *
+ * On read the adapter is deliberately tolerant: a naive string, an offset-bearing string, a
+ * date-only string, or epoch milliseconds are all accepted, because responses are not guaranteed to
+ * echo the format the SDK sent.
+ *
+ * @since 6.4.1
+ */
+public class NaiveDateTypeAdapter extends TypeAdapter {
+
+ /** The wire format for the 13 naive fields: no offset, no trailing {@code Z}. */
+ public static final String NAIVE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss";
+
+ private static final String[] READ_PATTERNS = {
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
+ NAIVE_PATTERN,
+ "yyyy-MM-dd HH:mm:ss",
+ "yyyy-MM-dd",
+ };
+
+ /**
+ * A {@link SimpleDateFormat} pinned to UTC. {@code SimpleDateFormat} is not thread safe, so a
+ * fresh instance is created per call rather than cached in a field.
+ *
+ *
+ * {@link Locale#US} is passed explicitly: the default locale can select a non-Gregorian
+ * calendar (Thai Buddhist, Japanese imperial), which would render a different year for the same
+ * instant on a differently configured JVM — the same class of machine-dependence as the
+ * timezone.
+ *
+ * @param pattern the {@link SimpleDateFormat} pattern
+ * @return a non-lenient formatter fixed to UTC and to {@link Locale#US}
+ */
+ public static SimpleDateFormat utcFormat(String pattern) {
+ SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.US);
+ format.setTimeZone(TimeZone.getTimeZone("UTC"));
+ format.setLenient(false);
+ return format;
+ }
+
+ @Override
+ public void write(JsonWriter out, Date value) throws IOException {
+ if (value == null) {
+ // Leaves the deferred field name unwritten while serializeNulls is off, so a null date
+ // is omitted exactly as it was before this adapter existed.
+ out.nullValue();
+ return;
+ }
+ out.value(utcFormat(NAIVE_PATTERN).format(value));
+ }
+
+ @Override
+ public Date read(JsonReader in) throws IOException {
+ JsonToken token = in.peek();
+ if (token == JsonToken.NULL) {
+ in.nextNull();
+ return null;
+ }
+ if (token == JsonToken.NUMBER) {
+ return new Date(in.nextLong());
+ }
+ String raw = in.nextString();
+ if (raw == null || raw.trim().isEmpty()) {
+ return null;
+ }
+ return parse(raw.trim());
+ }
+
+ /**
+ * Parses a date written in any of the formats the Riskified API is known to return.
+ *
+ * @param raw the date string, already trimmed
+ * @return the parsed date
+ * @throws JsonSyntaxException if no known format matches
+ */
+ public static Date parse(String raw) {
+ for (String pattern : READ_PATTERNS) {
+ try {
+ return utcFormat(pattern).parse(raw);
+ } catch (ParseException ignored) {
+ // try the next pattern
+ }
+ }
+ throw new JsonSyntaxException("Unparseable date: \"" + raw + "\"");
+ }
+}
diff --git a/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java b/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java
new file mode 100644
index 00000000..d03f8a5e
--- /dev/null
+++ b/riskified-sdk/src/main/java/com/riskified/adapters/PaymentDetailsAdapterFactory.java
@@ -0,0 +1,82 @@
+package com.riskified.adapters;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import com.riskified.models.IPaymentDetails;
+
+import java.io.IOException;
+
+/**
+ * Serializes each element of a {@code payment_details} array as its own runtime type, and emits
+ * no type discriminator.
+ *
+ *
+ * {@code payment_details} carries no discriminator on the wire: the variant is expressed by which
+ * keys are present, with the constant {@code payment_type} acting as the de-facto discriminator
+ * ({@code docs/flows/01-model-catalog.md} section 6). This SDK previously used a
+ * {@code RuntimeTypeAdapterFactory} for the dispatch, which injected a {@code "method"} key with
+ * values that disagreed with the {@code payment_type} emitted alongside it.
+ *
+ *
+ * Why a factory is still needed at all. Gson normally dispatches on an element's runtime type
+ * by itself, via {@code TypeAdapterRuntimeTypeWrapper}. It does not here: {@code BaseOrder} and
+ * {@code DecisionOrder} declare the field as {@code List extends IPaymentDetails>}, and Gson's
+ * runtime-type promotion fires only when the declared element type is a {@code Class} — a
+ * {@code WildcardType} is left alone. Without this factory the reflective adapter for the bare
+ * interface runs instead and every element serializes as {@code {}}: total, silent loss of the
+ * payment details. Dropping the old factory without replacing it is therefore not a safe no-op.
+ *
+ *
+ * Only the interface itself is matched, so the delegate lookup for the concrete class cannot re-enter
+ * this factory.
+ *
+ * @since 6.4.1
+ */
+public class PaymentDetailsAdapterFactory implements TypeAdapterFactory {
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public TypeAdapter create(Gson gson, TypeToken type) {
+ // Matched by raw type on purpose: the declared element type is a WildcardType
+ // (? extends IPaymentDetails), not IPaymentDetails.class, so TypeToken equality misses it.
+ if (type.getRawType() != IPaymentDetails.class) {
+ return null;
+ }
+ return (TypeAdapter) new PaymentDetailsTypeAdapter(gson);
+ }
+
+ private static class PaymentDetailsTypeAdapter extends TypeAdapter {
+
+ private final Gson gson;
+
+ PaymentDetailsTypeAdapter(Gson gson) {
+ this.gson = gson;
+ }
+
+ @Override
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ public void write(JsonWriter out, IPaymentDetails value) throws IOException {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+ TypeAdapter runtimeAdapter = gson.getAdapter(TypeToken.get(value.getClass()));
+ runtimeAdapter.write(out, value);
+ }
+
+ @Override
+ public IPaymentDetails read(JsonReader in) throws IOException {
+ // No response in the contract carries payment_details, and with no discriminator on the
+ // wire the variant is not recoverable from the keys alone. Deserialize the concrete
+ // class instead of the interface.
+ throw new JsonParseException("payment_details cannot be deserialized through the "
+ + "IPaymentDetails interface: it carries no type discriminator. Deserialize a "
+ + "concrete payment-details class instead.");
+ }
+ }
+}
diff --git a/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java
index 2f97435c..1b88d74c 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/AccommodationLineItem.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.Date;
import com.riskified.validations.*;
@@ -10,7 +14,11 @@ public class AccommodationLineItem extends LineItem {
private String roomType;
private String city;
private String countryCode;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date checkInDate;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date checkOutDate;
private String rating;
private Integer numberOfGuests;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java b/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java
index 098d277b..f5273350 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/AuthenticationResult.java
@@ -1,5 +1,7 @@
package com.riskified.models;
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.Date;
import com.google.gson.annotations.JsonAdapter;
@@ -14,6 +16,8 @@
public class AuthenticationResult implements IValidated {
private String eci;
private String cavv;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date createdAt;
private TransStatus transStatus;
private TransStatusReason transStatusReason;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/Customer.java b/riskified-sdk/src/main/java/com/riskified/models/Customer.java
index 30fbc17b..268c4d11 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/Customer.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/Customer.java
@@ -32,6 +32,8 @@ public class Customer implements IValidated {
private String documentType;
private String phone;
private Boolean verifiedPhone;
+ private Date verifiedEmailAt;
+ private Date firstPurchaseAt;
private Date verifiedPhoneAt;
private String userName;
private Boolean hasDefaulted;
@@ -257,6 +259,21 @@ public Integer getLinkedAccounts() {
public void setVerifiedPhone(Boolean verifiedPhone) { this.verifiedPhone = verifiedPhone; }
+ /**
+ * {@code verified_email_at} — {@code Customer.cs:143}, {@code DateTimeOffset?}. Offset-bearing,
+ * which is the default format, so it needs no adapter annotation.
+ */
+ public Date getVerifiedEmailAt() { return verifiedEmailAt; }
+
+ public void setVerifiedEmailAt(Date verifiedEmailAt) { this.verifiedEmailAt = verifiedEmailAt; }
+
+ /**
+ * {@code first_purchase_at} — {@code Customer.cs:167}, {@code DateTimeOffset?}. Offset-bearing.
+ */
+ public Date getFirstPurchaseAt() { return firstPurchaseAt; }
+
+ public void setFirstPurchaseAt(Date firstPurchaseAt) { this.firstPurchaseAt = firstPurchaseAt; }
+
public Date getVerifiedPhoneAt() { return verifiedPhoneAt; }
public void setVerifiedPhoneAt(Date verifiedPhoneAt) { this.verifiedPhoneAt = verifiedPhoneAt; }
diff --git a/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java
index c9232cea..11587bc6 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/EventLineItem.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.Date;
public class EventLineItem extends LineItem {
@@ -9,6 +13,8 @@ public class EventLineItem extends LineItem {
private String city;
private float latitude;
private float longitude;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date eventDate;
public EventLineItem(double price, int quantity, String title,
diff --git a/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java b/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java
index beca90cc..ef817015 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/KycDetails.java
@@ -1,5 +1,6 @@
package com.riskified.models;
+import com.google.gson.annotations.SerializedName;
import com.riskified.validations.FieldBadFormatException;
import com.riskified.validations.IValidated;
import com.riskified.validations.Validation;
@@ -9,6 +10,11 @@
public class KycDetails implements IValidated {
private String vendorName;
+ // The Java field name is updateAt, which LOWER_CASE_WITH_UNDERSCORES derives as "update_at".
+ // The contract key is "updated_at" (KycDetails.cs:18), so the derived name was a key the API
+ // ignores: KYC update timestamps were silently not arriving. Pinned explicitly rather than
+ // renaming the field, which would break every caller of getUpdateAt()/setUpdateAt().
+ @SerializedName("updated_at")
private Date updateAt;
private Boolean kyc_verified;
private String kycType;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/LineItem.java b/riskified-sdk/src/main/java/com/riskified/models/LineItem.java
index 037abfff..257f8995 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/LineItem.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/LineItem.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.*;
import java.util.jar.Attributes;
@@ -32,6 +36,8 @@ public class LineItem implements IValidated {
private String brand;
private String productType;
private String size;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date deliveredAt;
private String deliveredTo;
private String color;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/Login.java b/riskified-sdk/src/main/java/com/riskified/models/Login.java
index 4bf1fee4..2114c1e6 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/Login.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/Login.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import com.riskified.validations.*;
import java.util.Date;
@@ -7,6 +11,8 @@
public class Login implements IValidated {
private String customerId;
private String email;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date customerCreatedAt;
private Boolean loginAtCheckout;
private SocialType socialLoginType;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/Passenger.java b/riskified-sdk/src/main/java/com/riskified/models/Passenger.java
index afccf335..db15224f 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/Passenger.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/Passenger.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.Date;
import com.riskified.validations.*;
@@ -8,13 +12,19 @@ public class Passenger implements IValidated {
private String firstName;
private String lastName;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date dateOfBirth;
private String nationalityCode;
private String insuranceType;
private float insurancePrice;
private String documentNumber;
private String documentType;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date documentIssueDate;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date documentExpirationDate;
private String passengerType;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/Response.java b/riskified-sdk/src/main/java/com/riskified/models/Response.java
index f10d3db2..51507d77 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/Response.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/Response.java
@@ -20,7 +20,7 @@ public Response() {
public Response(CheckoutResponse checkoutResponse) {
this.order = checkoutResponse.getCheckout();
- this.received = checkoutResponse.getReceived();
+ this.received = checkoutResponse.getReceivedOrNull();
this.warnings = checkoutResponse.getWarnings();
this.error = checkoutResponse.getError();
}
@@ -45,7 +45,21 @@ public void setDecision(String decision) {
this.decision = decision;
}
+ /**
+ * @return the {@code received} count, or {@code 0} when the response carried no such field.
+ * Unboxing a null {@link Integer} here used to raise a {@link NullPointerException} on
+ * every response that omits it — which is every error response and several success
+ * ones. Use {@link #getReceivedOrNull()} when the difference between absent and zero
+ * matters; the primitive return type is kept so existing callers still compile.
+ */
public int getReceived() {
+ return received == null ? 0 : received;
+ }
+
+ /**
+ * @return the {@code received} count, or {@code null} when the response carried no such field.
+ */
+ public Integer getReceivedOrNull() {
return received;
}
diff --git a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java
index d8c69c01..7f709441 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/RideLineItem.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.lang.reflect.Field;
import java.util.Date;
@@ -8,11 +12,19 @@
public class RideLineItem extends LineItem {
// Ride Industry fields
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date pickupDate;
private Float pickupLatitude;
private Float pickupLongitude;
private Address pickupAddress;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date dropoffDate;
+ // Derives to "dropoff_latitude", which is the contract name in both OpenAPI specs.
+ // The reference C# SDK sends a transposed "dropoff_latitiude"
+ // (OrderElements/RideTicketLineItem.cs:100) -- that is a defect in that SDK, not the wire
+ // contract, so no @SerializedName override belongs here.
private Float dropoffLatitude;
private Float dropoffLongitude;
private Address dropoffAddress;
diff --git a/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java b/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java
index e96efcd2..b536e097 100644
--- a/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java
+++ b/riskified-sdk/src/main/java/com/riskified/models/TravelLineItem.java
@@ -1,5 +1,9 @@
package com.riskified.models;
+import com.google.gson.annotations.JsonAdapter;
+
+import com.riskified.adapters.NaiveDateTypeAdapter;
+
import java.util.Date;
import com.riskified.validations.*;
@@ -10,7 +14,11 @@ public class TravelLineItem extends LineItem {
private int legIndex;
private String departurePortCode;
private String arrivalPortCode;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date departureDate;
+ // Naive (offset-free) date on the wire; see NaiveDateTypeAdapter.
+ @JsonAdapter(NaiveDateTypeAdapter.class)
private Date arrivalDate;
private String departureCountryCode;
private String arrivalCountryCode;
diff --git a/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java
new file mode 100644
index 00000000..1cff016e
--- /dev/null
+++ b/riskified-sdk/src/test/java/com/riskified/DateSplitTest.java
@@ -0,0 +1,347 @@
+package com.riskified;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.riskified.adapters.NaiveDateTypeAdapter;
+import com.riskified.models.AccommodationLineItem;
+import com.riskified.models.AccountBalance;
+import com.riskified.models.AuthenticationResult;
+import com.riskified.models.AuthorizationError;
+import com.riskified.models.BaseOrder;
+import com.riskified.models.CancelOrder;
+import com.riskified.models.ChargebackDetails;
+import com.riskified.models.CreditCardPaymentDetails;
+import com.riskified.models.Customer;
+import com.riskified.models.DecisionDetails;
+import com.riskified.models.DisputeDetails;
+import com.riskified.models.EventLineItem;
+import com.riskified.models.FulfillmentDetails;
+import com.riskified.models.KycDetails;
+import com.riskified.models.LineItem;
+import com.riskified.models.Login;
+import com.riskified.models.Order;
+import com.riskified.models.Passenger;
+import com.riskified.models.RefundDetails;
+import com.riskified.models.RideLineItem;
+import com.riskified.models.SessionDetails;
+import com.riskified.models.TravelLineItem;
+import com.riskified.models.WalletPaymentDetails;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The date split.
+ *
+ *
+ * The contract carries two date formats. 25 fields are offset-bearing
+ * ({@code 2026-08-13T10:00:00+00:00}) and 13 are naive, with no offset at all
+ * ({@code 2026-08-13T10:00:00}). The reference implementation reaches the split through two CLR
+ * types and no configured converter, so the split follows no principle and has to be reproduced
+ * field by field. Both lists are in {@code docs/flows/01-model-catalog.md} section 3.
+ *
+ *
+ * Java has a single {@link Date}, so a single registered type adapter cannot express the split. The
+ * 13 naive fields carry {@code @JsonAdapter(NaiveDateTypeAdapter.class)}, which Gson gives
+ * precedence over the globally registered adapter; everything else takes the offset format.
+ */
+public class DateSplitTest {
+
+ /** 1970-01-01T00:00:00Z plus 10h — a fixed instant, so the expected strings are literals. */
+ private static final Date FIXED_INSTANT = new Date(36000000L);
+ private static final String EXPECTED_OFFSET_RENDERING = "1970-01-01T10:00:00+00:00";
+ private static final String EXPECTED_NAIVE_RENDERING = "1970-01-01T10:00:00";
+
+ private TimeZone originalTimeZone;
+
+ @Before
+ public void setUp() {
+ originalTimeZone = TimeZone.getDefault();
+ }
+
+ @After
+ public void tearDown() {
+ TimeZone.setDefault(originalTimeZone);
+ }
+
+ /**
+ * One offset-bearing field and one naive field in the same payload — the case the fleet report
+ * asks for as the first golden fixture.
+ */
+ @Test
+ public void testOneOffsetFieldAndOneNaiveFieldInTheSamePayload() {
+ Order inputOrder = new Order();
+ inputOrder.setId("ORDER-1");
+ inputOrder.setCreatedAt(FIXED_INSTANT);
+ RideLineItem inputRide = new RideLineItem(42.5, 1, "Airport transfer", FIXED_INSTANT, 0, 0);
+ List inputLineItems = new ArrayList();
+ inputLineItems.add(inputRide);
+ inputOrder.setLineItems(inputLineItems);
+
+ String actualJson = JSONFormater.toJson(inputOrder);
+
+ // created_at is one of the 25 — offset present.
+ assertTrue(actualJson, actualJson.contains("\"created_at\":\"" + EXPECTED_OFFSET_RENDERING + "\""));
+ // pickup_date is one of the 13 — no offset, and no trailing Z either.
+ assertTrue(actualJson, actualJson.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "\""));
+ assertFalse("pickup_date must not carry an offset: " + actualJson,
+ actualJson.contains("\"pickup_date\":\"" + EXPECTED_OFFSET_RENDERING + "\""));
+ assertFalse("no naive field may be rendered with a trailing Z: " + actualJson,
+ actualJson.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "Z\""));
+ }
+
+ /**
+ * The same {@link Date} must serialize identically whatever the JVM default timezone is. It did
+ * not before: the formatter used the default timezone, so two machines produced two different
+ * payloads — and two different HMACs — for the same object.
+ */
+ @Test
+ public void testSerializationIsIndependentOfTheJvmDefaultTimezone() {
+ Order inputOrder = new Order();
+ inputOrder.setCreatedAt(FIXED_INSTANT);
+ RideLineItem inputRide = new RideLineItem(1.0, 1, "Ride", FIXED_INSTANT, 0, 0);
+ List inputLineItems = new ArrayList();
+ inputLineItems.add(inputRide);
+ inputOrder.setLineItems(inputLineItems);
+
+ TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati")); // UTC+14
+ String actualJsonFarEast = JSONFormater.toJson(inputOrder);
+ TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Niue")); // UTC-11
+ String actualJsonFarWest = JSONFormater.toJson(inputOrder);
+
+ assertEquals(actualJsonFarEast, actualJsonFarWest);
+ assertTrue(actualJsonFarEast,
+ actualJsonFarEast.contains("\"created_at\":\"" + EXPECTED_OFFSET_RENDERING + "\""));
+ assertTrue(actualJsonFarEast,
+ actualJsonFarEast.contains("\"pickup_date\":\"" + EXPECTED_NAIVE_RENDERING + "\""));
+ }
+
+ /**
+ * All 13 naive fields of {@code docs/flows/01-model-catalog.md} section 3, by declaring class
+ * and Java field name. Two of the 25 offset-bearing fields have no Java counterpart at all and
+ * are recorded in {@link #testOffsetBearingFieldsCarryNoNaiveAdapter()}.
+ */
+ private static List