diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml
index 4bc266fd..f52db404 100644
--- a/.github/workflows/test-examples.yml
+++ b/.github/workflows/test-examples.yml
@@ -21,5 +21,9 @@ jobs:
         DEVCYCLE_SERVER_SDK_KEY: "${{ secrets.DEVCYCLE_SERVER_SDK_KEY }}"
     - name: Run cloud bucketing example
       run: ./gradlew runCloudExample
+      env:
+        DEVCYCLE_SERVER_SDK_KEY: "${{ secrets.DEVCYCLE_SERVER_SDK_KEY }}"
+    - name: Run OpenFeature example
+      run: ./gradlew runOpenFeatureExample
       env:
         DEVCYCLE_SERVER_SDK_KEY: "${{ secrets.DEVCYCLE_SERVER_SDK_KEY }}"
\ No newline at end of file
diff --git a/OpenFeature.md b/OpenFeature.md
new file mode 100644
index 00000000..f41283e8
--- /dev/null
+++ b/OpenFeature.md
@@ -0,0 +1,88 @@
+# DevCycle Java SDK OpenFeature Provider
+
+This SDK provides a Java implementation of the [OpenFeature](https://openfeature.dev/) Provider interface.
+
+## Example App
+
+See the [example app](src/examples/java/com/devcycle/examples/OpenFeatureExample.java) for a working example of the DevCycle Java SDK OpenFeature Provider.
+
+## Usage
+
+Start by creating the appropriate DevCycle SDK client (`DevCycleLocalClient` or `DevCycleCloudClient`).
+
+See our [Java Cloud Bucketing SDK](https://docs.devcycle.com/sdk/server-side-sdks/java-cloud) and [Java Local Bucketing SDK](https://docs.devcycle.com/sdk/server-side-sdks/java-local) documentation for more information on how to configure the SDK.
+
+```java
+// Initialize DevCycle Client
+DevCycleLocalOptions options = DevCycleLocalOptions.builder().build();
+DevCycleLocalClient devCycleClient = new DevCycleLocalClient("DEVCYCLE_SERVER_SDK_KEY", options);
+
+// Set the initialzed DevCycle client as the provider for OpenFeature
+OpenFeatureAPI api = OpenFeatureAPI.getInstance();
+api.setProvider(devCycleClient.getOpenFeatureProvider());
+        
+// Get the OpenFeature client
+Client openFeatureClient = api.getClient();
+
+// Create the evaluation context to use for fetching variable values
+EvaluationContext context = new MutableContext("user-1234");
+
+// Retrieve a boolean flag from the OpenFeature client
+Boolean variableValue = openFeatureClient.getBooleanValue(VARIABLE_KEY, false, context);
+```
+
+### Required Targeting Key
+
+For DevCycle SDK to work we require either a `targeting key` or `user_id` attribute to be set on the OpenFeature context.
+This value is used to identify the user as the `user_id` property for a `DevCycleUser` in DevCycle.
+
+### Mapping Context Properties to DevCycleUser
+
+The provider will automatically translate known `DevCycleUser` properties from the OpenFeature context to the `DevCycleUser` object.
+[DevCycleUser Java Interface](https://github.com/DevCycleHQ/java-server-sdk/blob/main/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java)
+
+For example all these properties will be set on the `DevCycleUser`:
+```java
+MutableContext context = new MutableContext("test-1234");
+context.add("email", "email@devcycle.com");
+context.add("name", "name");
+context.add("country", "CA");
+context.add("language", "en");
+context.add("appVersion", "1.0.11");
+context.add("appBuild", 1000);
+
+Map<String,Object> customData = new LinkedHashMap<>();
+customData.put("custom", "value");
+context.add("customData", Structure.mapToStructure(customData));
+
+Map<String,Object> privateCustomData = new LinkedHashMap<>();
+privateCustomData.put("private", "data");
+context.add("privateCustomData", Structure.mapToStructure(privateCustomData));
+```
+
+Context properties that are not known `DevCycleUser` properties will be automatically
+added to the `customData` property of the `DevCycleUser`.
+
+DevCycle allows the following data types for custom data values: **boolean**, **integer**, **double**, **float**, and **String**. Other data types will be ignored
+
+### JSON Flag Limitations
+
+The OpenFeature spec for JSON flags allows for any type of valid JSON value to be set as the flag value.
+
+For example the following are all valid default value types to use with OpenFeature:
+```java
+// Invalid JSON values for the DevCycle SDK, will return defaults
+openFeatureClient.getObjectValue("json-flag", new Value(new ArrayList<String>(Arrays.asList("value1", "value2"))));
+openFeatureClient.getObjectValue("json-flag", new Value(610));
+openFeatureClient.getObjectValue("json-flag", new Value(false));
+openFeatureClient.getObjectValue("json-flag", new Value("string"));
+openFeatureClient.getObjectValue("json-flag", new Value());
+```
+
+However, these are not valid types for the DevCycle SDK, the DevCycle SDK only supports JSON Objects:
+```java
+
+Map<String,Object> defaultJsonData = new LinkedHashMap<>();
+defaultJsonData.put("default", "value");
+openFeatureClient.getObjectValue("json-flag", new Value(Structure.mapToStructure(defaultJsonData)));
+```
\ No newline at end of file
diff --git a/README.md b/README.md
index f2adf934..dbe582bf 100755
--- a/README.md
+++ b/README.md
@@ -27,6 +27,13 @@ ldd --version
 
 ## Installation
 
+### Gradle
+You can use the SDK in your Gradle project by adding the following to *build.gradle*:
+
+```yaml
+implementation("com.devcycle:java-server-sdk:2.0.1")
+```
+
 ### Maven
 
 You can use the SDK in your Maven project by adding the following to your *pom.xml*:
@@ -40,13 +47,6 @@ You can use the SDK in your Maven project by adding the following to your *pom.x
 </dependency>
 ```
 
-### Gradle
-Alternatively you can use the SDK in your Gradle project by adding the following to *build.gradle*:
-
-```yaml
-implementation("com.devcycle:java-server-sdk:2.0.1")
-```
-
 ## DNS Caching
 The JVM, by default, caches DNS for infinity. DevCycle servers are load balanced and dynamic. To address this concern,
 setting the DNS cache TTL to a short duration is recommended. The TTL is controlled by this security setting `networkaddress.cache.ttl`.
@@ -84,9 +84,22 @@ public class MyClass {
 }
 ```
 
+## OpenFeature Support
+
+This SDK provides an implementation of the [OpenFeature](https://openfeature.dev/) Provider interface. Use the `getOpenFeatureProvider()` method on the DevCycle SDK client to obtain a provider for OpenFeature.
+
+```java
+DevCycleLocalClient devCycleClient = new DevCycleLocalClient("DEVCYCLE_SERVER_SDK_KEY", options);
+OpenFeatureAPI api = OpenFeatureAPI.getInstance();
+api.setProvider(devCycleClient.getOpenFeatureProvider());
+```
+
+You can find instructions on how to use it here: [DevCycle Java SDK OpenFeature Provider](OpenFeature.md)
+
+
 ## Usage
 
-To find usage documentation, visit our docs for [Local Bucketing](https://docs.devcycle.com/docs/sdk/server-side-sdks/java-local).
+To find usage documentation, visit our docs for [Local Bucketing](https://docs.devcycle.com/docs/sdk/server-side-sdks/java-local) and [Cloud Bucketing](https://docs.devcycle.com/docs/sdk/server-side-sdks/java-cloud)
 
 ## Logging
 
diff --git a/build.gradle b/build.gradle
index dec0a62b..56c28218 100644
--- a/build.gradle
+++ b/build.gradle
@@ -141,6 +141,7 @@ ext {
     junit_version = "4.13.2"
     mockito_core_version = "5.6.0"
     protobuf_version = "3.24.4"
+    openfeature_version = "1.6.1"
 }
 
 dependencies {
@@ -161,6 +162,8 @@ dependencies {
 
     implementation("com.google.protobuf:protobuf-java:$protobuf_version")
 
+    implementation("dev.openfeature:sdk:$openfeature_version")
+
     compileOnly("org.projectlombok:lombok:$lombok_version")
 
     testAnnotationProcessor("org.projectlombok:lombok:$lombok_version")
@@ -196,3 +199,9 @@ task runCloudExample(type: JavaExec) {
     classpath = sourceSets.examples.runtimeClasspath
     main = 'com.devcycle.examples.CloudExample'
 }
+
+task runOpenFeatureExample(type: JavaExec) {
+    description = "Run the OpenFeature example"
+    classpath = sourceSets.examples.runtimeClasspath
+    main = 'com.devcycle.examples.OpenFeatureExample'
+}
diff --git a/src/examples/java/com/devcycle/examples/CloudExample.java b/src/examples/java/com/devcycle/examples/CloudExample.java
index 6845626e..6c26e730 100644
--- a/src/examples/java/com/devcycle/examples/CloudExample.java
+++ b/src/examples/java/com/devcycle/examples/CloudExample.java
@@ -2,7 +2,6 @@
 
 import com.devcycle.sdk.server.cloud.api.DevCycleCloudClient;
 import com.devcycle.sdk.server.cloud.model.DevCycleCloudOptions;
-import com.devcycle.sdk.server.common.exception.DevCycleException;
 import com.devcycle.sdk.server.common.model.DevCycleUser;
 
 public class CloudExample {
@@ -34,16 +33,16 @@ public static void main(String[] args) throws InterruptedException {
         Boolean variableValue = false;
         try {
             variableValue = client.variableValue(user, VARIABLE_KEY, defaultValue);
-        } catch(DevCycleException e) {
+        } catch (IllegalArgumentException e) {
             System.err.println("Error fetching variable value: " + e.getMessage());
             System.exit(1);
         }
 
         // Use variable value
         if (variableValue) {
-            System.err.println("feature is enabled");
+            System.out.println("feature is enabled");
         } else {
-            System.err.println("feature is NOT enabled");
+            System.out.println("feature is NOT enabled");
         }
     }
 }
diff --git a/src/examples/java/com/devcycle/examples/LocalExample.java b/src/examples/java/com/devcycle/examples/LocalExample.java
index dd41e652..fad2665e 100644
--- a/src/examples/java/com/devcycle/examples/LocalExample.java
+++ b/src/examples/java/com/devcycle/examples/LocalExample.java
@@ -1,8 +1,8 @@
 package com.devcycle.examples;
 
+import com.devcycle.sdk.server.common.model.DevCycleUser;
 import com.devcycle.sdk.server.local.api.DevCycleLocalClient;
 import com.devcycle.sdk.server.local.model.DevCycleLocalOptions;
-import com.devcycle.sdk.server.common.model.DevCycleUser;
 
 public class LocalExample {
     public static String VARIABLE_KEY = "test-boolean-variable";
@@ -29,7 +29,7 @@ public static void main(String[] args) throws InterruptedException {
         DevCycleLocalClient client = new DevCycleLocalClient(server_sdk_key, options);
 
         for (int i = 0; i < 10; i++) {
-            if(client.isInitialized()) {
+            if (client.isInitialized()) {
                 break;
             }
             Thread.sleep(500);
@@ -42,9 +42,9 @@ public static void main(String[] args) throws InterruptedException {
 
         // Use variable value
         if (variableValue) {
-            System.err.println("feature is enabled");
+            System.out.println("feature is enabled");
         } else {
-            System.err.println("feature is NOT enabled");
+            System.out.println("feature is NOT enabled");
         }
     }
 }
diff --git a/src/examples/java/com/devcycle/examples/OpenFeatureExample.java b/src/examples/java/com/devcycle/examples/OpenFeatureExample.java
new file mode 100644
index 00000000..9e671ccf
--- /dev/null
+++ b/src/examples/java/com/devcycle/examples/OpenFeatureExample.java
@@ -0,0 +1,85 @@
+package com.devcycle.examples;
+
+import com.devcycle.sdk.server.local.api.DevCycleLocalClient;
+import com.devcycle.sdk.server.local.model.DevCycleLocalOptions;
+import dev.openfeature.sdk.*;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class OpenFeatureExample {
+    public static void main(String[] args) throws InterruptedException {
+        String server_sdk_key = System.getenv("DEVCYCLE_SERVER_SDK_KEY");
+        if (server_sdk_key == null) {
+            System.err.println("Please set the DEVCYCLE_SERVER_SDK_KEY environment variable");
+            System.exit(1);
+        }
+
+        DevCycleLocalOptions options = DevCycleLocalOptions.builder().configPollingIntervalMS(60000)
+                .disableAutomaticEventLogging(false).disableCustomEventLogging(false).build();
+
+        // Initialize DevCycle Client
+        DevCycleLocalClient devCycleClient = new DevCycleLocalClient(server_sdk_key, options);
+
+        for (int i = 0; i < 10; i++) {
+            if (devCycleClient.isInitialized()) {
+                break;
+            }
+            Thread.sleep(500);
+        }
+
+        // Setup OpenFeature with the DevCycle Provider
+        OpenFeatureAPI api = OpenFeatureAPI.getInstance();
+        api.setProvider(devCycleClient.getOpenFeatureProvider());
+
+        Client openFeatureClient = api.getClient();
+
+        // Create the evaluation context to use for fetching variable values
+        MutableContext context = new MutableContext("test-1234");
+        context.add("email", "test-user@domain.com");
+        context.add("name", "Test User");
+        context.add("language", "en");
+        context.add("country", "CA");
+        context.add("appVersion", "1.0.0");
+        context.add("appBuild", "1");
+        context.add("deviceModel", "Macbook");
+
+        // Add Devcycle Custom Data values
+        Map<String,Object> customData = new LinkedHashMap<>();
+        customData.put("custom", "value");
+        context.add("customData", Structure.mapToStructure(customData));
+
+        // Add Devcycle Private Custom Data values
+        Map<String,Object> privateCustomData = new LinkedHashMap<>();
+        privateCustomData.put("private", "data");
+        context.add("privateCustomData", Structure.mapToStructure(privateCustomData));
+
+        // The default value can be of type string, boolean, number, or JSON
+        Boolean defaultValue = false;
+
+        // Fetch variable values using the identifier key, with a default value and user
+        // object. The default value can be of type string, boolean, number, or JSON
+        Boolean variableValue = openFeatureClient.getBooleanValue("test-boolean-variable", defaultValue, context);
+
+        // Use variable value
+        if (variableValue) {
+            System.out.println("feature is enabled");
+        } else {
+            System.out.println("feature is NOT enabled");
+        }
+
+        // Default JSON objects must be a map of string to primitive values
+        Map<String, Object> defaultJsonData = new LinkedHashMap<>();
+        defaultJsonData.put("default", "value");
+
+        // Fetch a JSON object variable
+        Value jsonObject = openFeatureClient.getObjectValue("test-json-variable", new Value(Structure.mapToStructure(defaultJsonData)), context);
+        System.out.println(jsonObject.toString());
+
+        // Retrieving a string variable along with the resolution details
+        FlagEvaluationDetails<String> details = openFeatureClient.getStringDetails("doesnt-exist", "default", context);
+        System.out.println("Value: " + details.getValue());
+        System.out.println("Reason: " + details.getReason());
+
+    }
+}
diff --git a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java
index 64500216..2f887415 100755
--- a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java
+++ b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java
@@ -2,14 +2,17 @@
 
 import com.devcycle.sdk.server.cloud.model.DevCycleCloudOptions;
 import com.devcycle.sdk.server.common.api.IDevCycleApi;
+import com.devcycle.sdk.server.common.api.IDevCycleClient;
 import com.devcycle.sdk.server.common.exception.DevCycleException;
 import com.devcycle.sdk.server.common.logging.DevCycleLogger;
 import com.devcycle.sdk.server.common.model.*;
 import com.devcycle.sdk.server.common.model.Variable.TypeEnum;
+import com.devcycle.sdk.server.openfeature.DevCycleProvider;
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import dev.openfeature.sdk.FeatureProvider;
 import retrofit2.Call;
 import retrofit2.Response;
 
@@ -18,247 +21,274 @@
 import java.util.HashMap;
 import java.util.Map;
 
-public final class DevCycleCloudClient {
+public final class DevCycleCloudClient implements IDevCycleClient {
 
-  private final IDevCycleApi api;
-  private final DevCycleCloudOptions dvcOptions;
+    private final IDevCycleApi api;
+    private final DevCycleCloudOptions dvcOptions;
+    private final DevCycleProvider openFeatureProvider;
 
-  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
-  public DevCycleCloudClient(String sdkKey) {
-    this(sdkKey, DevCycleCloudOptions.builder().build());
-  }
-
-  public DevCycleCloudClient(String sdkKey, DevCycleCloudOptions options) {
-    if(sdkKey == null || sdkKey.equals("")) {
-      throw new IllegalArgumentException("Missing environment key! Call initialize with a valid environment key");
+    public DevCycleCloudClient(String sdkKey) {
+        this(sdkKey, DevCycleCloudOptions.builder().build());
     }
 
-    if(!isValidServerKey(sdkKey)) {
-      throw new IllegalArgumentException("Invalid environment key provided. Please call initialize with a valid server environment key");
-    }
+    public DevCycleCloudClient(String sdkKey, DevCycleCloudOptions options) {
+        if (sdkKey == null || sdkKey.equals("")) {
+            throw new IllegalArgumentException("Missing environment key! Call initialize with a valid environment key");
+        }
+
+        if (!isValidServerKey(sdkKey)) {
+            throw new IllegalArgumentException("Invalid environment key provided. Please call initialize with a valid server environment key");
+        }
 
-    if(options.getCustomLogger() != null)
-    {
-        DevCycleLogger.setCustomLogger(options.getCustomLogger());
+        if (options.getCustomLogger() != null) {
+            DevCycleLogger.setCustomLogger(options.getCustomLogger());
+        }
+
+        this.dvcOptions = options;
+        api = new DevCycleCloudApiClient(sdkKey, options).initialize();
+        OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+
+        this.openFeatureProvider = new DevCycleProvider(this);
     }
 
-    this.dvcOptions = options;
-    api = new DevCycleCloudApiClient(sdkKey, options).initialize();
-    OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL);
-  }
-
-  /**
-   * Get all features for user data
-   * 
-   * @param user  (required)
-   * @return Map&gt;String, Feature&lt;
-   */
-  public Map<String, Feature> allFeatures(DevCycleUser user) throws DevCycleException {
-    validateUser(user);
-
-    Call<Map<String, Feature>> response = api.getFeatures(user, dvcOptions.getEnableEdgeDB());
-    return getResponse(response);
-  }
-
-  /**
-   * Get variable value by key for user data
-   *
-   * @param user (required)
-   * @param key  Feature key (required)
-   * @param defaultValue Default value to use if the variable could not be fetched
-   *                     (required)
-   * @return Variable value
-   * @throws DevCycleException If there are any uses with the data provided
-   */
-  public <T> T variableValue(DevCycleUser user, String key, T defaultValue)  throws DevCycleException {
-    return variable(user, key, defaultValue).getValue();
-  }
-
-  /**
-   * Get variable by key for user data
-   * 
-   * @param user  (required)
-   * @param key Variable key (required)
-   * @param defaultValue Default value to use if the variable could not be fetched (required)
-   * @return Variable
-   * @throws DevCycleException If there are any uses with the data provided
-   */
-  @SuppressWarnings("unchecked")
-  public <T> Variable<T> variable(DevCycleUser user, String key, T defaultValue) throws DevCycleException {
-    validateUser(user);
-
-    if (key == null || key.equals("")) {
-      ErrorResponse errorResponse = new ErrorResponse(500, "Missing parameter: key", null);
-      throw new DevCycleException(HttpResponseCode.byCode(500), errorResponse);
+    /**
+     * Get all features for user data
+     *
+     * @param user (required)
+     * @return Map&gt;String, Feature&lt;
+     */
+    public Map<String, Feature> allFeatures(DevCycleUser user) throws DevCycleException {
+        validateUser(user);
+
+        Call<Map<String, Feature>> response = api.getFeatures(user, dvcOptions.getEnableEdgeDB());
+        return getResponse(response);
     }
 
-    if (defaultValue == null) {
-      ErrorResponse errorResponse = new ErrorResponse(500, "Missing parameter: defaultValue", null);
-      throw new DevCycleException(HttpResponseCode.byCode(500), errorResponse);
+    @Override
+    public boolean isInitialized() {
+        // Cloud Bucketing SDKs always count as initialized
+        return true;
     }
 
-    TypeEnum variableType = TypeEnum.fromClass(defaultValue.getClass());
-    Variable<T> variable;
-
-    try {
-      Call<Variable> response = api.getVariableByKey(user, key, dvcOptions.getEnableEdgeDB());
-      variable = getResponseWithRetries(response, 5);
-      if (variable.getType() != variableType) {
-        throw new IllegalArgumentException("Variable type mismatch, returning default value");
-      }
-      variable.setIsDefaulted(false);
-    } catch (Exception exception) {
-      variable = (Variable<T>) Variable.builder()
-          .key(key)
-          .type(variableType)
-          .value(defaultValue)
-          .defaultValue(defaultValue)
-          .isDefaulted(true)
-          .build();
+    /**
+     * Get variable value by key for user data
+     *
+     * @param user         (required)
+     * @param key          Feature key (required)
+     * @param defaultValue Default value to use if the variable could not be fetched
+     *                     (required)
+     * @return Variable value
+     * @throws IllegalArgumentException If there are any issues with the key or default value provided
+     */
+    public <T> T variableValue(DevCycleUser user, String key, T defaultValue) {
+        return variable(user, key, defaultValue).getValue();
     }
-    return variable;
-  }
-
-  /**
-   * Get all variables by key for user data
-   * 
-   * @param user  (required)
-   * @return Map&gt;String, BaseVariable&lt;
-   */
-  public Map<String, BaseVariable> allVariables(DevCycleUser user) throws DevCycleException {
-    validateUser(user);
-
-    Call<Map<String, BaseVariable>> response = api.getVariables(user, dvcOptions.getEnableEdgeDB());
-    try {
-      Map<String, BaseVariable> variablesResponse = getResponse(response);
-      return variablesResponse;
-    } catch (DevCycleException exception) {
-      if (exception.getHttpResponseCode() == HttpResponseCode.SERVER_ERROR) {
-        return new HashMap<>();
-      }
-      throw exception;
+
+    /**
+     * Get variable by key for user data
+     *
+     * @param user         (required)
+     * @param key          Variable key (required)
+     * @param defaultValue Default value to use if the variable could not be fetched (required)
+     * @return Variable
+     * @throws IllegalArgumentException If there are any issues with the key or default value provided
+     */
+    @SuppressWarnings("unchecked")
+    public <T> Variable<T> variable(DevCycleUser user, String key, T defaultValue) {
+        validateUser(user);
+
+        if (key == null || key.equals("")) {
+            ErrorResponse errorResponse = new ErrorResponse(500, "Missing parameter: key", null);
+            throw new IllegalArgumentException("Missing parameter: key");
+        }
+
+        if (defaultValue == null) {
+            ErrorResponse errorResponse = new ErrorResponse(500, "Missing parameter: defaultValue", null);
+            throw new IllegalArgumentException("Missing parameter: defaultValue");
+        }
+
+        TypeEnum variableType = TypeEnum.fromClass(defaultValue.getClass());
+        Variable<T> variable;
+
+        try {
+            Call<Variable> response = api.getVariableByKey(user, key, dvcOptions.getEnableEdgeDB());
+            variable = getResponseWithRetries(response, 5);
+            if (variable.getType() != variableType) {
+                throw new IllegalArgumentException("Variable type mismatch, returning default value");
+            }
+            variable.setIsDefaulted(false);
+        } catch (Exception exception) {
+            variable = (Variable<T>) Variable.builder()
+                    .key(key)
+                    .type(variableType)
+                    .value(defaultValue)
+                    .defaultValue(defaultValue)
+                    .isDefaulted(true)
+                    .build();
+        }
+        return variable;
     }
-  }
-
-  /**
-   * Post events to DevCycle for user
-   * 
-   * @param user  (required)
-   * @param event  (required)
-   */
-  public void track(DevCycleUser user, DevCycleEvent event) throws DevCycleException {
-    validateUser(user);
-
-    if (event == null || event.getType() == null || event.getType().equals("")) {
-      throw new IllegalArgumentException("Invalid DevCycleEvent");
+
+    @Override
+    public void close() {
+        // no-op - Cloud SDKs don't hold onto any resources
     }
 
-    DevCycleUserAndEvents userAndEvents = DevCycleUserAndEvents.builder()
-            .user(user)
-            .events(Collections.singletonList(event))
-            .build();
 
-    Call<DevCycleResponse> response = api.track(userAndEvents, dvcOptions.getEnableEdgeDB());
-    getResponseWithRetries(response, 5);
-  }
+    /**
+     * @return the OpenFeature provider for this client.
+     */
+    @Override
+    public FeatureProvider getOpenFeatureProvider() {
+        return this.openFeatureProvider;
+    }
 
+    @Override
+    public String getSDKPlatform() {
+        return "Cloud";
+    }
 
-  private <T> T getResponseWithRetries(Call<T> call, int maxRetries) throws DevCycleException {
-    // attempt 0 is the initial request, attempt > 0 are all retries
-    int attempt = 0;
-    do {
-      try {
-        return getResponse(call);
-      } catch (DevCycleException e) {
-        attempt++;
+    /**
+     * Get all variables by key for user data
+     *
+     * @param user (required)
+     * @return Map&gt;String, BaseVariable&lt;
+     */
+    public Map<String, BaseVariable> allVariables(DevCycleUser user) throws DevCycleException {
+        validateUser(user);
 
-        // if out of retries or this is an unauthorized error, throw up exception
-        if (!e.isRetryable() || attempt > maxRetries) {
-          throw e;
+        Call<Map<String, BaseVariable>> response = api.getVariables(user, dvcOptions.getEnableEdgeDB());
+        try {
+            Map<String, BaseVariable> variablesResponse = getResponse(response);
+            return variablesResponse;
+        } catch (DevCycleException exception) {
+            if (exception.getHttpResponseCode() == HttpResponseCode.SERVER_ERROR) {
+                return new HashMap<>();
+            }
+            throw exception;
         }
+    }
 
-        try {
-          // exponential backoff
-          long waitIntervalMS = (long) (10 * Math.pow(2, attempt));
-          Thread.sleep(waitIntervalMS);
-        } catch (InterruptedException ex) {
-          // no-op
+    /**
+     * Post events to DevCycle for user
+     *
+     * @param user  (required)
+     * @param event (required)
+     */
+    public void track(DevCycleUser user, DevCycleEvent event) throws DevCycleException {
+        validateUser(user);
+
+        if (event == null || event.getType() == null || event.getType().equals("")) {
+            throw new IllegalArgumentException("Invalid DevCycleEvent");
         }
 
-        // prep the call for a retry
-        call = call.clone();
-      }
-    }while (attempt <= maxRetries);
-
-    // getting here should not happen, but is technically possible
-    ErrorResponse errorResponse = ErrorResponse.builder().build();
-    errorResponse.setMessage("Out of retry attempts");
-    throw new DevCycleException(HttpResponseCode.SERVER_ERROR, errorResponse);
-  }
-
-
-  private <T> T getResponse(Call<T> call) throws DevCycleException {
-    ErrorResponse errorResponse = ErrorResponse.builder().build();
-    Response<T> response;
-
-    try {
-      response = call.execute();
-    } catch(MismatchedInputException mie) {
-      // got a badly formatted JSON response from the server
-      errorResponse.setMessage(mie.getMessage());
-      throw new DevCycleException(HttpResponseCode.NO_CONTENT, errorResponse);
-    } catch (IOException e) {
-      // issues reaching the server or reading the response
-      errorResponse.setMessage(e.getMessage());
-      throw new DevCycleException(HttpResponseCode.byCode(500), errorResponse);
-    }
+        DevCycleUserAndEvents userAndEvents = DevCycleUserAndEvents.builder()
+                .user(user)
+                .events(Collections.singletonList(event))
+                .build();
 
-    HttpResponseCode httpResponseCode = HttpResponseCode.byCode(response.code());
-    errorResponse.setMessage("Unknown error");
-
-    if (response.errorBody() != null) {
-      try {
-        errorResponse = OBJECT_MAPPER.readValue(response.errorBody().string(), ErrorResponse.class);
-      } catch (IOException e) {
-        errorResponse.setMessage(e.getMessage());
-        throw new DevCycleException(httpResponseCode, errorResponse);
-      }
-      throw new DevCycleException(httpResponseCode, errorResponse);
+        Call<DevCycleResponse> response = api.track(userAndEvents, dvcOptions.getEnableEdgeDB());
+        getResponseWithRetries(response, 5);
     }
 
-    if (response.body() == null) {
-      throw new DevCycleException(httpResponseCode, errorResponse);
+
+    private <T> T getResponseWithRetries(Call<T> call, int maxRetries) throws DevCycleException {
+        // attempt 0 is the initial request, attempt > 0 are all retries
+        int attempt = 0;
+        do {
+            try {
+                return getResponse(call);
+            } catch (DevCycleException e) {
+                attempt++;
+
+                // if out of retries or this is an unauthorized error, throw up exception
+                if (!e.isRetryable() || attempt > maxRetries) {
+                    throw e;
+                }
+
+                try {
+                    // exponential backoff
+                    long waitIntervalMS = (long) (10 * Math.pow(2, attempt));
+                    Thread.sleep(waitIntervalMS);
+                } catch (InterruptedException ex) {
+                    // no-op
+                }
+
+                // prep the call for a retry
+                call = call.clone();
+            }
+        } while (attempt <= maxRetries);
+
+        // getting here should not happen, but is technically possible
+        ErrorResponse errorResponse = ErrorResponse.builder().build();
+        errorResponse.setMessage("Out of retry attempts");
+        throw new DevCycleException(HttpResponseCode.SERVER_ERROR, errorResponse);
     }
 
-    if (response.isSuccessful()) {
-      return response.body();
-    } else {
-      if (httpResponseCode == HttpResponseCode.UNAUTHORIZED) {
-        errorResponse.setMessage("Invalid SDK Key");
-      } else if (!response.message().equals("")) {
+
+    private <T> T getResponse(Call<T> call) throws DevCycleException {
+        ErrorResponse errorResponse = ErrorResponse.builder().build();
+        Response<T> response;
+
         try {
-          errorResponse = OBJECT_MAPPER.readValue(response.message(), ErrorResponse.class);
-        } catch (JsonProcessingException e) {
-          errorResponse.setMessage(e.getMessage());
-          throw new DevCycleException(httpResponseCode, errorResponse);
+            response = call.execute();
+        } catch (MismatchedInputException mie) {
+            // got a badly formatted JSON response from the server
+            errorResponse.setMessage(mie.getMessage());
+            throw new DevCycleException(HttpResponseCode.NO_CONTENT, errorResponse);
+        } catch (IOException e) {
+            // issues reaching the server or reading the response
+            errorResponse.setMessage(e.getMessage());
+            throw new DevCycleException(HttpResponseCode.byCode(500), errorResponse);
         }
-      }
 
-      throw new DevCycleException(httpResponseCode, errorResponse);
-    }
-  }
+        HttpResponseCode httpResponseCode = HttpResponseCode.byCode(response.code());
+        errorResponse.setMessage("Unknown error");
+
+        if (response.errorBody() != null) {
+            try {
+                errorResponse = OBJECT_MAPPER.readValue(response.errorBody().string(), ErrorResponse.class);
+            } catch (IOException e) {
+                errorResponse.setMessage(e.getMessage());
+                throw new DevCycleException(httpResponseCode, errorResponse);
+            }
+            throw new DevCycleException(httpResponseCode, errorResponse);
+        }
+
+        if (response.body() == null) {
+            throw new DevCycleException(httpResponseCode, errorResponse);
+        }
 
-  private boolean isValidServerKey(String serverKey) {
-    return serverKey.startsWith("server") || serverKey.startsWith("dvc_server");
-  }
+        if (response.isSuccessful()) {
+            return response.body();
+        } else {
+            if (httpResponseCode == HttpResponseCode.UNAUTHORIZED) {
+                errorResponse.setMessage("Invalid SDK Key");
+            } else if (!response.message().equals("")) {
+                try {
+                    errorResponse = OBJECT_MAPPER.readValue(response.message(), ErrorResponse.class);
+                } catch (JsonProcessingException e) {
+                    errorResponse.setMessage(e.getMessage());
+                    throw new DevCycleException(httpResponseCode, errorResponse);
+                }
+            }
+
+            throw new DevCycleException(httpResponseCode, errorResponse);
+        }
+    }
 
-  private void validateUser(DevCycleUser user) {
-    if (user == null) {
-      throw new IllegalArgumentException("DevCycleUser cannot be null");
+    private boolean isValidServerKey(String serverKey) {
+        return serverKey.startsWith("server") || serverKey.startsWith("dvc_server");
     }
-    if (user.getUserId().equals("")) {
-      throw new IllegalArgumentException("userId cannot be empty");
+
+    private void validateUser(DevCycleUser user) {
+        if (user == null) {
+            throw new IllegalArgumentException("DevCycleUser cannot be null");
+        }
+        if (user.getUserId().equals("")) {
+            throw new IllegalArgumentException("userId cannot be empty");
+        }
     }
-  }
 }
diff --git a/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java b/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java
new file mode 100644
index 00000000..9ccc241c
--- /dev/null
+++ b/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java
@@ -0,0 +1,46 @@
+package com.devcycle.sdk.server.common.api;
+
+import com.devcycle.sdk.server.common.model.DevCycleUser;
+import com.devcycle.sdk.server.common.model.Variable;
+import dev.openfeature.sdk.FeatureProvider;
+
+/**
+ * Base interface for DevCycle clients that can be used to evaluate Features and retrieve variables values.
+ */
+public interface IDevCycleClient {
+    /**
+     * @return true if the client is initialized and ready to be used. Clients should
+     * return a default value if they are not initialized.
+     */
+    boolean isInitialized();
+
+    /**
+     * @param user         (required) The user context for the evaluation.
+     * @param key          (required) The key of the feature variable to evaluate.
+     * @param defaultValue (required) The default value to return if the feature variable is not found or the user
+     *                     does not segment into the feature
+     * @return the value of the variable for the given user, or the default value if the variable is not found.
+     */
+    <T> T variableValue(DevCycleUser user, String key, T defaultValue);
+
+    /**
+     * @param user         (required) The user context for the evaluation.
+     * @param key          (required) The key of the feature variable to evaluate.
+     * @param defaultValue (required) The default value to return if the feature variable is not found or the user
+     *                     does not segment into the feature
+     * @return the variable for the given user, or the default variable if the variable is not found.
+     */
+    <T> Variable<T> variable(DevCycleUser user, String key, T defaultValue);
+
+    /**
+     * Close the client and release any resources.
+     */
+    void close();
+
+    /**
+     * @return the OpenFeature provider for this client.
+     */
+    FeatureProvider getOpenFeatureProvider();
+
+    String getSDKPlatform();
+}
diff --git a/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java b/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java
index 15988377..bfd200e4 100755
--- a/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java
+++ b/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java
@@ -3,7 +3,7 @@
  * Documents the DevCycle Bucketing API which provides and API interface to User Bucketing and for generated SDKs.
  *
  * OpenAPI spec version: 1.0.0
- * 
+ *
  *
  * NOTE: This class is auto generated by the swagger code generator program.
  * https://github.com/swagger-api/swagger-codegen.git
@@ -17,10 +17,15 @@
 import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.Structure;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.exceptions.TargetingKeyMissingError;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.*;
 import lombok.extern.jackson.Jacksonized;
 
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 @Data
@@ -30,81 +35,168 @@
 @AllArgsConstructor
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class DevCycleUser {
-  @NonNull
-  @Schema(required = true, description = "Unique id to identify the user")
-  @JsonProperty("user_id")
-  private String userId;
-
-  @Schema(description = "User's email used to identify the user on the dashboard / target audiences")
-  @JsonProperty("email")
-  private String email;
-
-  @Schema(description = "User's name used to identify the user on the dashboard / target audiences")
-  @JsonProperty("name")
-  private String name;
-
-  @Schema(description = "User's language in ISO 639-1 format")
-  @JsonProperty("language")
-  private String language;
-
-  @Schema(description = "User's country in ISO 3166 alpha-2 format")
-  @JsonProperty("country")
-  private String country;
-
-  @Schema(description = "App Version of the running application")
-  @JsonProperty("appVersion")
-  private String appVersion;
-
-  @Schema(description = "App Build number of the running application")
-  @JsonProperty("appBuild")
-  private String appBuild;
-
-  @Schema(description = "User's custom data to target the user with, data will be logged to DevCycle for use in dashboard.")
-  @JsonProperty("customData")
-  private Map<String, Object> customData;
-
-  @Schema(description = "User's custom data to target the user with, data will not be logged to DevCycle only used for feature bucketing.")
-  @JsonProperty("privateCustomData")
-  private Map<String, Object> privateCustomData;
-
-  @Schema(description = "Date the user was created, Unix epoch timestamp format")
-  @JsonProperty("createdDate")
-  @JsonDeserialize(using = LongTimestampDeserializer.class)
-  @JsonFormat(shape = JsonFormat.Shape.STRING)
-  private Long createdDate;
-
-  @Schema(description = "Date the user was last seen, Unix epoch timestamp format")
-  @JsonProperty("lastSeenDate")
-  @JsonDeserialize(using = LongTimestampDeserializer.class)
-  @JsonFormat(shape = JsonFormat.Shape.STRING)
-  private Long lastSeenDate;
-
-  @Schema(description = "Platform the SDK is running on")
-  @Builder.Default
-  @JsonProperty("platform")
-  private String platform = getPlatformData().getPlatform();
-
-  @Schema(description = "Version of the platform the SDK is running on")
-  @Builder.Default
-  @JsonProperty("platformVersion")
-  private String platformVersion = getPlatformData().getPlatformVersion();
-
-  @Schema(description = "DevCycle SDK type")
-  @Builder.Default
-  @JsonProperty("sdkType")
-  private PlatformData.SdkTypeEnum sdkType = getPlatformData().getSdkType();
-
-  @Schema(description = "DevCycle SDK Version")
-  @Builder.Default
-  @JsonProperty("sdkVersion")
-  private String sdkVersion = getPlatformData().getSdkVersion();
-
-  @Schema(description = "Hostname where the SDK is running")
-  @Builder.Default
-  @JsonProperty("hostname")
-  private String hostname = getPlatformData().getHostname();
-
-  public static PlatformData getPlatformData() {
-    return PlatformData.builder().build();
-  }
+    @NonNull
+    @Schema(required = true, description = "Unique id to identify the user")
+    @JsonProperty("user_id")
+    private String userId;
+
+    @Schema(description = "User's email used to identify the user on the dashboard / target audiences")
+    @JsonProperty("email")
+    private String email;
+
+    @Schema(description = "User's name used to identify the user on the dashboard / target audiences")
+    @JsonProperty("name")
+    private String name;
+
+    @Schema(description = "User's language in ISO 639-1 format")
+    @JsonProperty("language")
+    private String language;
+
+    @Schema(description = "User's country in ISO 3166 alpha-2 format")
+    @JsonProperty("country")
+    private String country;
+
+    @Schema(description = "App Version of the running application")
+    @JsonProperty("appVersion")
+    private String appVersion;
+
+    @Schema(description = "App Build number of the running application")
+    @JsonProperty("appBuild")
+    private String appBuild;
+
+    @Schema(description = "User's custom data to target the user with, data will be logged to DevCycle for use in dashboard.")
+    @JsonProperty("customData")
+    private Map<String, Object> customData;
+
+    @Schema(description = "User's custom data to target the user with, data will not be logged to DevCycle only used for feature bucketing.")
+    @JsonProperty("privateCustomData")
+    private Map<String, Object> privateCustomData;
+
+    @Schema(description = "Date the user was created, Unix epoch timestamp format")
+    @JsonProperty("createdDate")
+    @JsonDeserialize(using = LongTimestampDeserializer.class)
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
+    private Long createdDate;
+
+    @Schema(description = "Date the user was last seen, Unix epoch timestamp format")
+    @JsonProperty("lastSeenDate")
+    @JsonDeserialize(using = LongTimestampDeserializer.class)
+    @JsonFormat(shape = JsonFormat.Shape.STRING)
+    private Long lastSeenDate;
+
+    @Schema(description = "Platform the SDK is running on")
+    @Builder.Default
+    @JsonProperty("platform")
+    private String platform = getPlatformData().getPlatform();
+
+    @Schema(description = "Version of the platform the SDK is running on")
+    @Builder.Default
+    @JsonProperty("platformVersion")
+    private String platformVersion = getPlatformData().getPlatformVersion();
+
+    @Schema(description = "DevCycle SDK type")
+    @Builder.Default
+    @JsonProperty("sdkType")
+    private PlatformData.SdkTypeEnum sdkType = getPlatformData().getSdkType();
+
+    @Schema(description = "DevCycle SDK Version")
+    @Builder.Default
+    @JsonProperty("sdkVersion")
+    private String sdkVersion = getPlatformData().getSdkVersion();
+
+    @Schema(description = "Hostname where the SDK is running")
+    @Builder.Default
+    @JsonProperty("hostname")
+    private String hostname = getPlatformData().getHostname();
+
+    public static PlatformData getPlatformData() {
+        return PlatformData.builder().build();
+    }
+
+    static void setCustomValue(Map<String, Object> customData, String key, Value value) {
+        // Only support boolean, number, string and null types for custom data values
+        // ignore all other data
+        if (key != null && value != null) {
+            if (value.isBoolean()) {
+                customData.put(key, value.asBoolean());
+            } else if (value.isNumber()) {
+                customData.put(key, value.asDouble());
+            } else if (value.isString()) {
+                customData.put(key, value.asString());
+            } else if (value.isNull()) {
+                customData.put(key, null);
+            }
+        }
+    }
+
+    /**
+     * Create a DevCycleUser from an EvaluationContext
+     *
+     * @param ctx A context to load a targeting key and user data from
+     * @return An initialized DevCycleUser with data from the context
+     * @throws TargetingKeyMissingError if the targeting key or user_id attribute is not set
+     */
+    public static DevCycleUser fromEvaluationContext(EvaluationContext ctx) {
+        String userId = "";
+
+        if (ctx != null && ctx.getTargetingKey() != null && !ctx.getTargetingKey().isEmpty()) {
+            userId = ctx.getTargetingKey();
+        } else if (ctx != null && ctx.getValue("user_id") != null) {
+            userId = ctx.getValue("user_id").asString();
+        }
+
+        if (userId == null || userId.isEmpty()) {
+            throw new TargetingKeyMissingError();
+        }
+
+        DevCycleUser user = DevCycleUser.builder().userId(userId).build();
+
+        Map<String, Object> customData = new LinkedHashMap<>();
+        Map<String, Object> privateCustomData = new LinkedHashMap<>();
+
+        for (String key : ctx.keySet()) {
+            if (key.equals("user_id")) {
+                continue;
+            }
+
+            Value value = ctx.getValue(key);
+            if (value != null) {
+                if (key.equals("email") && value.isString()) {
+                    user.setEmail(value.asString());
+                } else if (key.equals("name") && value.isString()) {
+                    user.setName(value.asString());
+                } else if (key.equals("language") && value.isString()) {
+                    user.setLanguage(value.asString());
+                } else if (key.equals("country") && value.isString()) {
+                    user.setCountry(value.asString());
+                } else if (key.equals("appVersion") && value.isString()) {
+                    user.setAppVersion(value.asString());
+                } else if (key.equals("appBuild") && value.isString()) {
+                    user.setAppBuild(value.asString());
+                } else if (key.equals("customData") && value.isStructure()) {
+                    Structure customDataStructure = value.asStructure();
+                    for (String dataKey : customDataStructure.keySet()) {
+                        setCustomValue(customData, dataKey, customDataStructure.getValue(dataKey));
+                    }
+                } else if (key.equals("privateCustomData") && value.isStructure()) {
+                    Structure privateDataStructure = value.asStructure();
+                    for (String dataKey : privateDataStructure.keySet()) {
+                        setCustomValue(privateCustomData, dataKey, privateDataStructure.getValue(dataKey));
+                    }
+                } else {
+                    setCustomValue(customData, key, value);
+                }
+            }
+        }
+
+        if (!customData.isEmpty()) {
+            user.setCustomData(customData);
+        }
+
+        if (!privateCustomData.isEmpty()) {
+            user.setPrivateCustomData(privateCustomData);
+        }
+
+        return user;
+    }
 }
\ No newline at end of file
diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java
index 1d628daa..30d5afbb 100755
--- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java
+++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java
@@ -1,5 +1,6 @@
 package com.devcycle.sdk.server.local.api;
 
+import com.devcycle.sdk.server.common.api.IDevCycleClient;
 import com.devcycle.sdk.server.common.model.*;
 import com.devcycle.sdk.server.common.model.Variable.TypeEnum;
 import com.devcycle.sdk.server.local.bucketing.LocalBucketing;
@@ -12,13 +13,15 @@
 import com.devcycle.sdk.server.local.protobuf.VariableType_PB;
 import com.devcycle.sdk.server.common.logging.DevCycleLogger;
 import com.devcycle.sdk.server.local.utils.ProtobufUtils;
+import com.devcycle.sdk.server.openfeature.DevCycleProvider;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.openfeature.sdk.FeatureProvider;
 
 import java.util.Collections;
 import java.util.Map;
 
-public final class DevCycleLocalClient {
+public final class DevCycleLocalClient implements IDevCycleClient {
 
   private LocalBucketing localBucketing = new LocalBucketing();
 
@@ -28,6 +31,7 @@ public final class DevCycleLocalClient {
 
   private EventQueueManager eventQueueManager;
 
+  private final DevCycleProvider openFeatureProvider;
   public DevCycleLocalClient(String sdkKey) {
     this(sdkKey, DevCycleLocalOptions.builder().build());
   }
@@ -57,6 +61,7 @@ public DevCycleLocalClient(String sdkKey, DevCycleLocalOptions dvcOptions) {
     } catch (Exception e) {
         DevCycleLogger.error("Error creating event queue due to error: " + e.getMessage());
     }
+    this.openFeatureProvider = new DevCycleProvider(this);
   }
 
   /**
@@ -245,6 +250,19 @@ public void close() {
     }
   }
 
+  /**
+   * @return the OpenFeature provider for this client.
+   */
+  @Override
+  public FeatureProvider getOpenFeatureProvider() {
+    return this.openFeatureProvider;
+  }
+
+  @Override
+  public String getSDKPlatform() {
+    return "Local";
+  }
+
   private void validateUser(DevCycleUser user) {
     if (user == null) {
       throw new IllegalArgumentException("DevCycleUser cannot be null");
diff --git a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java
new file mode 100644
index 00000000..3590d7a7
--- /dev/null
+++ b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java
@@ -0,0 +1,144 @@
+package com.devcycle.sdk.server.openfeature;
+
+import com.devcycle.sdk.server.common.api.IDevCycleClient;
+import com.devcycle.sdk.server.common.model.DevCycleUser;
+import com.devcycle.sdk.server.common.model.Variable;
+import dev.openfeature.sdk.*;
+import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
+import dev.openfeature.sdk.exceptions.TypeMismatchError;
+
+import java.util.Map;
+
+public class DevCycleProvider implements FeatureProvider {
+    private static final String PROVIDER_NAME = "DevCycle";
+
+    private final IDevCycleClient devcycleClient;
+
+    public DevCycleProvider(IDevCycleClient devcycleClient) {
+        this.devcycleClient = devcycleClient;
+    }
+
+    @Override
+    public Metadata getMetadata() {
+        return () -> PROVIDER_NAME + " " + devcycleClient.getSDKPlatform();
+    }
+
+    @Override
+    public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
+        return resolvePrimitiveVariable(key, defaultValue, ctx);
+    }
+
+    @Override
+    public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
+        return resolvePrimitiveVariable(key, defaultValue, ctx);
+    }
+
+    @Override
+    public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
+        ProviderEvaluation<Integer> eval = resolvePrimitiveVariable(key, defaultValue, ctx);
+        return eval;
+    }
+
+    @Override
+    public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
+        return resolvePrimitiveVariable(key, defaultValue, ctx);
+    }
+
+    @Override
+    public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
+        /*
+         * JSON objects have special rules in the DevCycle SDK and must be handled differently
+         * - must always be an object, no lists or literal values
+         * - must only contain strings, numbers, and booleans
+         */
+        if (!defaultValue.isStructure()) {
+            throw new TypeMismatchError("Default value must be a OpenFeature structure");
+        }
+
+        for (String k : defaultValue.asStructure().keySet()) {
+            Value v = defaultValue.asStructure().getValue(k);
+            if (!(v.isString() || v.isNumber() || v.isBoolean() || v.isNull())) {
+                throw new TypeMismatchError("DevCycle JSON objects may only contain strings, numbers, booleans and nulls");
+            }
+        }
+
+        if (devcycleClient.isInitialized()) {
+            try {
+                DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx);
+
+                Variable<Object> variable = devcycleClient.variable(user, key, defaultValue.asStructure().asObjectMap());
+
+                if (variable == null || variable.getIsDefaulted()) {
+                    return ProviderEvaluation.<Value>builder()
+                            .value(defaultValue)
+                            .reason(Reason.DEFAULT.toString())
+                            .build();
+                } else {
+                    if (variable.getValue() instanceof Map) {
+                        // JSON objects are managed as Map implementations and must be converted to an OpenFeature structure
+                        Value objectValue = new Value(Structure.mapToStructure((Map) variable.getValue()));
+                        return ProviderEvaluation.<Value>builder()
+                                .value(objectValue)
+                                .reason(Reason.TARGETING_MATCH.toString())
+                                .build();
+                    } else {
+                        throw new TypeMismatchError("DevCycle variable for key " + key + " is not a JSON object");
+                    }
+                }
+            } catch (IllegalArgumentException e) {
+                return ProviderEvaluation.<Value>builder()
+                        .value(defaultValue)
+                        .reason(Reason.ERROR.toString())
+                        .errorCode(ErrorCode.GENERAL)
+                        .errorMessage(e.getMessage())
+                        .build();
+            }
+        } else {
+            throw new ProviderNotReadyError("DevCycle client not initialized");
+        }
+    }
+
+    @Override
+    public void shutdown() {
+        devcycleClient.close();
+    }
+
+    <T> ProviderEvaluation<T> resolvePrimitiveVariable(String key, T defaultValue, EvaluationContext ctx) {
+        if (devcycleClient.isInitialized()) {
+            try {
+                DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx);
+
+                Variable<T> variable = devcycleClient.variable(user, key, defaultValue);
+
+                if (variable == null || variable.getIsDefaulted()) {
+                    return ProviderEvaluation.<T>builder()
+                            .value(defaultValue)
+                            .reason(Reason.DEFAULT.toString())
+                            .build();
+                } else {
+                    T value = variable.getValue();
+                    if (variable.getType() == Variable.TypeEnum.NUMBER && defaultValue.getClass() == Integer.class) {
+                        // Internally in the DevCycle SDK all number values are stored as Doubles
+                        // need to explicitly convert to an Integer if the requested type is Integer
+                        Number numVal = (Number) value;
+                        value = (T) Integer.valueOf(numVal.intValue());
+                    }
+
+                    return ProviderEvaluation.<T>builder()
+                            .value(value)
+                            .reason(Reason.TARGETING_MATCH.toString())
+                            .build();
+                }
+            } catch (IllegalArgumentException e) {
+                return ProviderEvaluation.<T>builder()
+                        .value(defaultValue)
+                        .reason(Reason.ERROR.toString())
+                        .errorCode(ErrorCode.GENERAL)
+                        .errorMessage(e.getMessage())
+                        .build();
+            }
+        } else {
+            throw new ProviderNotReadyError("DevCycle client not initialized");
+        }
+    }
+}
diff --git a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java
index 47df0ce2..86cd51ca 100755
--- a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java
+++ b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java
@@ -86,7 +86,7 @@ public void getVariableByKeyTest() {
             assertUserDefaultsCorrect(user);
 
             Assert.assertFalse(variable.getValue());
-        } catch (DevCycleException e) {
+        } catch (IllegalArgumentException e) {
             e.printStackTrace();
         }
     }
@@ -107,7 +107,7 @@ public void getVariableValueByKeyTest() {
             assertUserDefaultsCorrect(user);
 
             Assert.assertFalse(value);
-        } catch (DevCycleException e) {
+        } catch (IllegalArgumentException e) {
             e.printStackTrace();
         }
     }
@@ -155,7 +155,7 @@ public void variable_emptyKey_throwsException() {
                 .build();
 
         Assert.assertThrows("Missing parameter: key",
-                DevCycleException.class, () -> api.variable(user, null, true));
+                IllegalArgumentException.class, () -> api.variable(user, null, true));
     }
 
     @Test
@@ -165,7 +165,7 @@ public void variable_emptyDefaultValue_throwsException() {
                 .build();
 
         Assert.assertThrows("Missing parameter: defaultValue",
-        DevCycleException.class, () -> api.variable(user, "wibble", null));
+        IllegalArgumentException.class, () -> api.variable(user, "wibble", null));
     }
 
     @Test
diff --git a/src/test/java/com/devcycle/sdk/server/common/model/DevCycleUserTest.java b/src/test/java/com/devcycle/sdk/server/common/model/DevCycleUserTest.java
new file mode 100644
index 00000000..99a4d550
--- /dev/null
+++ b/src/test/java/com/devcycle/sdk/server/common/model/DevCycleUserTest.java
@@ -0,0 +1,209 @@
+package com.devcycle.sdk.server.common.model;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.MutableContext;
+import dev.openfeature.sdk.Structure;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.exceptions.TargetingKeyMissingError;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.*;
+
+public class DevCycleUserTest {
+
+    @Test
+    public void testCreateUserNoUserID() {
+        MutableContext ctx = new MutableContext();
+
+        try {
+            DevCycleUser.fromEvaluationContext(ctx);
+            Assert.fail("Expected exception");
+        } catch (TargetingKeyMissingError e) {
+            // expected
+        }
+
+        Map<String, Value> attribs = new LinkedHashMap();
+        ctx = new MutableContext(null, attribs);
+
+        try {
+            DevCycleUser.fromEvaluationContext(ctx);
+            Assert.fail("Expected exception");
+        } catch (TargetingKeyMissingError e) {
+            // expected
+        }
+
+        attribs = new LinkedHashMap();
+        attribs.put("user_id", new Value(999));
+        ctx = new MutableContext(null, attribs);
+
+        try {
+            DevCycleUser.fromEvaluationContext(ctx);
+            Assert.fail("Expected exception");
+        } catch (TargetingKeyMissingError e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void testCreateUserOnlyUserId() {
+        EvaluationContext ctx = new MutableContext("test-1234");
+        DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "test-1234");
+
+        Map<String, Value> apiAttrs = new LinkedHashMap();
+        apiAttrs.put("user_id", new Value("test-6789"));
+
+        // ensure fallback to user_id when target key is null
+        ctx = new MutableContext(null, apiAttrs);
+        user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "test-6789");
+
+        // ensure fallback to user_id when target key is empty
+        ctx = new MutableContext("", apiAttrs);
+        user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "test-6789");
+
+        // ensure target key takes precedence over user_id
+        ctx = new MutableContext("user-4567", apiAttrs);
+        user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "user-4567");
+    }
+
+    @Test
+    public void testCreateUserWithAttributes() {
+        Map<String, Value> apiAttrs = new LinkedHashMap();
+        apiAttrs.put("email", new Value("test-user@domain.com"));
+        apiAttrs.put("country", new Value("US"));
+
+        apiAttrs.put("name", new Value("John Doe"));
+        apiAttrs.put("language", new Value("en"));
+        apiAttrs.put("appVersion", new Value("1.0.0"));
+        apiAttrs.put("appBuild", new Value("1"));
+
+        EvaluationContext ctx = new MutableContext("test-1234", apiAttrs);
+
+        DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "test-1234");
+        Assert.assertEquals(user.getEmail(), "test-user@domain.com");
+        Assert.assertEquals(user.getCountry(), "US");
+        Assert.assertEquals(user.getName(), "John Doe");
+        Assert.assertEquals(user.getLanguage(), "en");
+        Assert.assertEquals(user.getAppVersion(), "1.0.0");
+        Assert.assertEquals(user.getAppBuild(), "1");
+    }
+
+    @Test
+    public void testCreateUserWithCustomData() {
+        Map<String, Value> apiAttrs = new LinkedHashMap();
+
+        Map<String, Object> customData = new LinkedHashMap();
+        customData.put("strValue", "hello");
+        customData.put("intValue", 123);
+        customData.put("floatValue", 3.1456);
+        customData.put("boolValue", true);
+        apiAttrs.put("customData", new Value(Structure.mapToStructure(customData)));
+
+        Map<String, Object> privateCustomData = new LinkedHashMap();
+        privateCustomData.put("strValue", "world");
+        privateCustomData.put("intValue", 789);
+        privateCustomData.put("floatValue", 0.0001);
+        privateCustomData.put("boolValue", false);
+
+        apiAttrs.put("privateCustomData", new Value(Structure.mapToStructure(privateCustomData)));
+
+        EvaluationContext ctx = new MutableContext("test-1234", apiAttrs);
+
+        DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx);
+        Assert.assertEquals(user.getUserId(), "test-1234");
+
+        Assert.assertEquals(user.getCustomData().size(), 4);
+        Assert.assertEquals(user.getCustomData().get("strValue"), "hello");
+        Assert.assertEquals(user.getCustomData().get("intValue"), 123.0);
+        Assert.assertEquals(user.getCustomData().get("floatValue"), 3.1456);
+        Assert.assertEquals(user.getCustomData().get("boolValue"), true);
+
+        Assert.assertEquals(user.getPrivateCustomData().size(), 4);
+        Assert.assertEquals(user.getPrivateCustomData().get("strValue"), "world");
+        Assert.assertEquals(user.getPrivateCustomData().get("intValue"), 789.0);
+        Assert.assertEquals(user.getPrivateCustomData().get("floatValue"), 0.0001);
+        Assert.assertEquals(user.getPrivateCustomData().get("boolValue"), false);
+    }
+
+    @Test
+    public void testSetCustomValueBadData() {
+        Assert.assertThrows(NullPointerException.class, () -> {
+            DevCycleUser.setCustomValue(null, "test", new Value(true));
+        });
+
+        Map<String, Object> customData = new HashMap();
+        DevCycleUser.setCustomValue(customData, null, new Value(true));
+        Assert.assertEquals(customData.size(), 0);
+
+        DevCycleUser.setCustomValue(customData, "test", null);
+        Assert.assertEquals(customData.size(), 0);
+
+        List list = new ArrayList();
+        list.add("one");
+        list.add("two");
+        list.add("three");
+
+        customData = new HashMap();
+        DevCycleUser.setCustomValue(customData, "test", new Value(list));
+        Assert.assertEquals(customData.size(), 0);
+
+        Map map = new HashMap();
+        map.put("p1", "one");
+        map.put("p2", "two");
+        map.put("p3", "three");
+
+        customData = new HashMap();
+        DevCycleUser.setCustomValue(customData, "test", new Value(Structure.mapToStructure(map)));
+        Assert.assertEquals(customData.size(), 0);
+    }
+
+    @Test
+    public void testSetCustomValueNull() {
+        Map<String, Object> customData = new HashMap();
+
+        DevCycleUser.setCustomValue(customData, "test", new Value());
+        Assert.assertEquals(customData.size(), 1);
+        Assert.assertNull(customData.get("test"));
+    }
+
+    @Test
+    public void testSetCustomValueBoolean() {
+        Map<String, Object> customData = new HashMap();
+
+        DevCycleUser.setCustomValue(customData, "test", new Value(true));
+        Assert.assertEquals(customData.size(), 1);
+        Assert.assertEquals(customData.get("test"), true);
+    }
+
+    @Test
+    public void testSetCustomValueString() {
+        Map<String, Object> customData = new HashMap();
+
+        DevCycleUser.setCustomValue(customData, "test", new Value("some string"));
+        Assert.assertEquals(customData.size(), 1);
+        Assert.assertEquals(customData.get("test"), "some string");
+    }
+
+    @Test
+    public void testSetCustomValueInt() {
+        Map<String, Object> customData = new HashMap();
+
+        DevCycleUser.setCustomValue(customData, "test", new Value(999));
+        Assert.assertEquals(customData.size(), 1);
+        Assert.assertEquals(customData.get("test"), 999.0);
+    }
+
+    @Test
+    public void testSetCustomValueDouble() {
+        Map<String, Object> customData = new HashMap();
+
+        DevCycleUser.setCustomValue(customData, "test", new Value(3.14));
+        Assert.assertEquals(customData.size(), 1);
+        Assert.assertEquals(customData.get("test"), 3.14);
+    }
+}
diff --git a/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java b/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java
new file mode 100644
index 00000000..3dc7045c
--- /dev/null
+++ b/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java
@@ -0,0 +1,38 @@
+package com.devcycle.sdk.server.helpers;
+
+import com.devcycle.sdk.server.common.api.IDevCycleClient;
+import com.devcycle.sdk.server.common.model.DevCycleUser;
+import com.devcycle.sdk.server.common.model.Variable;
+import com.devcycle.sdk.server.openfeature.DevCycleProvider;
+
+public class MockDevCycleClient implements IDevCycleClient {
+    @Override
+    public boolean isInitialized() {
+        return false;
+    }
+
+    @Override
+    public <T> T variableValue(DevCycleUser user, String key, T defaultValue) {
+        return null;
+    }
+
+    @Override
+    public <T> Variable<T> variable(DevCycleUser user, String key, T defaultValue) {
+        return null;
+    }
+
+    @Override
+    public void close() {
+
+    }
+
+    @Override
+    public DevCycleProvider getOpenFeatureProvider() {
+        return null;
+    }
+
+    @Override
+    public String getSDKPlatform() {
+        return "Mock";
+    }
+}
diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java
new file mode 100644
index 00000000..58c6f731
--- /dev/null
+++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java
@@ -0,0 +1,154 @@
+package com.devcycle.sdk.server.openfeature;
+
+import com.devcycle.sdk.server.helpers.LocalConfigServer;
+import com.devcycle.sdk.server.helpers.TestDataFixtures;
+import com.devcycle.sdk.server.local.api.DevCycleLocalClient;
+import com.devcycle.sdk.server.local.model.DevCycleLocalOptions;
+import dev.openfeature.sdk.*;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Integration tests for the OpenFeature provider that uses the Local SDK client to verify that
+ * data conversions are working as expected
+ */
+public class DevCycleProviderLocalSDKTest {
+    private static DevCycleLocalClient client;
+    private static LocalConfigServer localConfigServer;
+    static final String apiKey = String.format("server-%s", UUID.randomUUID());
+
+    @BeforeClass
+    public static void setup() throws Exception {
+        // spin up a lightweight http server to serve the config and properly initialize the client
+        localConfigServer = new LocalConfigServer(TestDataFixtures.SmallConfig(), 9000);
+        localConfigServer.start();
+        client = createClient(TestDataFixtures.SmallConfig());
+    }
+
+    private static DevCycleLocalClient createClient(String config) {
+        localConfigServer.setConfigData(config);
+
+        DevCycleLocalOptions options = DevCycleLocalOptions.builder()
+                .configCdnBaseUrl(localConfigServer.getHostRootURL())
+                .configPollingIntervalMS(60000)
+                .build();
+
+        DevCycleLocalClient client = new DevCycleLocalClient(apiKey, options);
+        try {
+            int loops = 0;
+            while (!client.isInitialized()) {
+                // wait for the client to load the config and initialize
+                Thread.sleep(100);
+                loops++;
+                // wait a max 10 seconds to initialize client before failing completely
+                if (loops >= 100) {
+                    throw new RuntimeException("Client failed to initialize in 10 seconds");
+                }
+            }
+        } catch (InterruptedException e) {
+            // no-op
+        }
+        return client;
+    }
+
+    @AfterClass
+    public static void cleanup() throws Exception {
+        client.close();
+        localConfigServer.stop();
+    }
+
+    private EvaluationContext getContext() {
+        Map<String, Value> attributes = new HashMap<>();
+        attributes.put("email", new Value("giveMeVariationOn@email.com"));
+        return new ImmutableContext("j_test", attributes);
+    }
+
+    @Test
+    public void testGetObjectEvaluation() {
+        EvaluationContext ctx = getContext();
+        String key = "json-var";
+        Map<String, Object> defaultJSON = new LinkedHashMap<>();
+        defaultJSON.put("displayText", "This variation is off");
+        defaultJSON.put("showDialog", false);
+        defaultJSON.put("maxUsers", 0);
+
+        Value defaultValue = new Value(Structure.mapToStructure(defaultJSON));
+
+        ProviderEvaluation<Value> result = client.getOpenFeatureProvider().getObjectEvaluation(key, defaultValue, ctx);
+
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertTrue(result.getValue().isStructure());
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+
+        Map<String, Object> variableData = result.getValue().asStructure().asObjectMap();
+        Assert.assertEquals("This variation is on", variableData.get("displayText"));
+        Assert.assertEquals(true, variableData.get("showDialog"));
+        Assert.assertEquals(100, variableData.get("maxUsers"));
+    }
+
+    @Test
+    public void testGetBooleanEvaluation() {
+        EvaluationContext ctx = getContext();
+        String key = "a-cool-new-feature";
+        Boolean defaultValue = false;
+        Boolean expectedValue = true;
+        ProviderEvaluation<Boolean> result = client.getOpenFeatureProvider().getBooleanEvaluation(key, defaultValue, ctx);
+
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertEquals(expectedValue, result.getValue());
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+    }
+
+    @Test
+    public void testGetIntegerEvaluation() {
+        EvaluationContext ctx = getContext();
+        String key = "num-var";
+        Integer defaultValue = 0;
+        Integer expectedValue = 12345;
+        ProviderEvaluation<Integer> result = client.getOpenFeatureProvider().getIntegerEvaluation(key, defaultValue, ctx);
+
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertEquals(expectedValue, result.getValue());
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+    }
+
+    @Test
+    public void testGetDoubleEvaluation() {
+        EvaluationContext ctx = getContext();
+        String key = "num-var";
+        Double defaultValue = 0.0;
+        Double expectedValue = 12345.0;
+        ProviderEvaluation<Double> result = client.getOpenFeatureProvider().getDoubleEvaluation(key, defaultValue, ctx);
+
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertEquals(expectedValue, result.getValue());
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+    }
+
+    @Test
+    public void testGetStringEvaluation() {
+        EvaluationContext ctx = getContext();
+        String key = "string-var";
+        String defaultValue = "default string";
+        String expectedValue = "variationOn";
+        ProviderEvaluation<String> result = client.getOpenFeatureProvider().getStringEvaluation(key, defaultValue, ctx);
+
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertEquals(expectedValue, result.getValue());
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+    }
+
+
+}
diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java
new file mode 100644
index 00000000..ddaa88d4
--- /dev/null
+++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderTest.java
@@ -0,0 +1,252 @@
+package com.devcycle.sdk.server.openfeature;
+
+import com.devcycle.sdk.server.common.api.IDevCycleClient;
+import com.devcycle.sdk.server.common.model.Variable;
+import dev.openfeature.sdk.*;
+import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
+import dev.openfeature.sdk.exceptions.TargetingKeyMissingError;
+import dev.openfeature.sdk.exceptions.TypeMismatchError;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.*;
+
+import static org.mockito.Mockito.*;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DevCycleProviderTest {
+
+    @Test
+    public void testResolveClientNotInitialized() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(false);
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+        Assert.assertThrows(
+                ProviderNotReadyError.class,
+                () -> provider.resolvePrimitiveVariable("some-flag", false, new ImmutableContext("test-1234")));
+    }
+
+    @Test
+    public void testResolveNoUserInContext() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        Assert.assertThrows(
+                TargetingKeyMissingError.class,
+                () -> provider.resolvePrimitiveVariable("some-flag", false, new ImmutableContext()));
+    }
+
+    @Test
+    public void testResolveNoKey() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+        when(dvcClient.variable(any(), any(), any())).thenThrow(IllegalArgumentException.class);
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Boolean> result = provider.resolvePrimitiveVariable(null, false, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), false);
+        Assert.assertEquals(result.getReason(), Reason.ERROR.toString());
+        Assert.assertEquals(result.getErrorCode(), ErrorCode.GENERAL);
+    }
+
+    @Test
+    public void testResolveNoDefaultValue() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+        when(dvcClient.variable(any(), any(), any())).thenThrow(IllegalArgumentException.class);
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Boolean> result = provider.resolvePrimitiveVariable("some-flag", null, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertNull(result.getValue());
+        Assert.assertEquals(result.getReason(), Reason.ERROR.toString());
+        Assert.assertEquals(result.getErrorCode(), ErrorCode.GENERAL);
+    }
+
+
+    @Test
+    public void testResolveNoVariableFound() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+        when(dvcClient.variable(any(), any(), any())).thenReturn(null);
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Boolean> result = provider.resolvePrimitiveVariable("some-flag", false, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), false);
+        Assert.assertEquals(result.getReason(), Reason.DEFAULT.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testResolveVariableDefaulted() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value("unused value 1").defaultValue("default value").isDefaulted(true).type(Variable.TypeEnum.STRING).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<String> result = provider.resolvePrimitiveVariable("some-flag", "default value", new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), "default value");
+        Assert.assertEquals(result.getReason(), Reason.DEFAULT.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testResolveBooleanVariable() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(true).defaultValue(false).type(Variable.TypeEnum.BOOLEAN).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Boolean> result = provider.resolvePrimitiveVariable("some-flag", false, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), true);
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testResolveIntegerVariable() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+
+        Integer variableValue = 1234;
+        Integer defaultValue = 0;
+
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(variableValue).defaultValue(defaultValue).type(Variable.TypeEnum.NUMBER).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Integer> result = provider.resolvePrimitiveVariable("some-flag", defaultValue, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), variableValue);
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testResolveDoubleVariable() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+
+        Double variableValue = 1.234;
+        Double defaultValue = 0.0;
+
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(variableValue).defaultValue(defaultValue).type(Variable.TypeEnum.NUMBER).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<Double> result = provider.resolvePrimitiveVariable("some-flag", defaultValue, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), variableValue);
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testResolveStringVariable() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+
+        String variableValue = "some value";
+        String defaultValue = "";
+
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(variableValue).defaultValue(defaultValue).type(Variable.TypeEnum.STRING).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        ProviderEvaluation<String> result = provider.resolvePrimitiveVariable("some-flag", defaultValue, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertEquals(result.getValue(), variableValue);
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+
+    @Test
+    public void testGetObjectEvaluationBadDefault() {
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        String key = "json-var";
+
+        Assert.assertThrows(
+                TypeMismatchError.class,
+                () -> provider.getObjectEvaluation(key, new Value(1234), new ImmutableContext("user-1234")));
+
+
+        Assert.assertThrows(
+                TypeMismatchError.class,
+                () -> provider.getObjectEvaluation(key, new Value("basic string"), new ImmutableContext("user-1234")));
+
+
+        Assert.assertThrows(
+                TypeMismatchError.class,
+                () -> provider.getObjectEvaluation(key, new Value(true), new ImmutableContext("user-1234")));
+
+        List<Value> someList = new ArrayList<>();
+        someList.add(new Value("some string"));
+        someList.add(new Value(1234));
+        Assert.assertThrows(
+                TypeMismatchError.class,
+                () -> provider.getObjectEvaluation(key, new Value(someList), new ImmutableContext("user-1234")));
+
+        // Test a valid structure but with data DevCycle can't use
+        Map<String, Object> nestedMap = new HashMap<>();
+        nestedMap.put("nestedKey", "nestedValue");
+
+        Map<String, Object> someMap = new HashMap<>();
+        someMap.put("nestedMap", new Value(Structure.mapToStructure(nestedMap)));
+
+        List<Value> nestedList = new ArrayList<>();
+        nestedList.add(new Value("string"));
+        nestedList.add(new Value(1234));
+        someMap.put("nestedList", new Value(nestedList));
+
+        Assert.assertThrows(
+                TypeMismatchError.class,
+                () -> provider.getObjectEvaluation(key, new Value(Structure.mapToStructure(someMap)), new ImmutableContext("user-1234")));
+    }
+
+    @Test
+    public void testGetObjectEvaluation() {
+        Map<String, Object> jsonData = new LinkedHashMap<>();
+        jsonData.put("strVal", "some string");
+        jsonData.put("boolVal", true);
+        jsonData.put("numVal", 123);
+
+        Map<String, Object> defaultJsonData = new LinkedHashMap<>();
+
+        IDevCycleClient dvcClient = mock(IDevCycleClient.class);
+        when(dvcClient.isInitialized()).thenReturn(true);
+        when(dvcClient.variable(any(), any(), any())).thenReturn(Variable.builder().key("some-flag").value(jsonData).defaultValue(defaultJsonData).type(Variable.TypeEnum.JSON).build());
+
+        DevCycleProvider provider = new DevCycleProvider(dvcClient);
+
+        Value defaultValue = new Value(Structure.mapToStructure(defaultJsonData));
+
+        ProviderEvaluation<Value> result = provider.getObjectEvaluation("some-flag", defaultValue, new ImmutableContext("user-1234"));
+        Assert.assertNotNull(result);
+        Assert.assertNotNull(result.getValue());
+        Assert.assertTrue(result.getValue().isStructure());
+
+        result.getValue().asStructure().asObjectMap().forEach((k, v) -> {
+            Assert.assertTrue(jsonData.containsKey(k));
+            Assert.assertEquals(jsonData.get(k), v);
+        });
+
+        Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString());
+        Assert.assertNull(result.getErrorCode());
+    }
+}
