Skip to content

Repository files navigation

Schematic Java Library

The official Schematic Java library.

Java version requirements:

  • Core SDK (flag checks, events, webhooks): Java 8+
  • Datastream / local flag evaluation: Java 11+ (required by the WASM runtime)

Enabling datastream on a JVM older than 11 will throw a DataStreamException with a clear message at Schematic build time, rather than a cryptic UnsupportedClassVersionError at runtime.

Installation and Setup

  1. Add the dependency using your build tool of choice:

Using Gradle in build.gradle:

dependencies {
    implementation 'com.schematichq:schematic-java:0.x.x'
}

Using Maven in pom.xml:

<dependency>
    <groupId>com.schematichq</groupId>
    <artifactId>schematic-java</artifactId>
    <version>0.x.x</version>
</dependency>
  1. Issue an API key for the appropriate environment using the Schematic app.

  2. Using this secret key, initialize a client in your application:

import com.schematic.api.Schematic;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .build();

Usage

A number of these examples use keys to identify companies and users. Learn more about keys here.

Sending identify events

Create or update users and companies using identify events.

import com.fasterxml.jackson.databind.JsonNode;
import com.schematic.api.Schematic;
import com.schematic.api.core.ObjectMappers;
import java.util.HashMap;
import java.util.Map;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .build();

Map<String, String> keys = new HashMap<>();
keys.put("email", "wcoyote@acme.net");
keys.put("user_id", "your-user-id");

EventBodyIdentifyCompany company = EventBodyIdentifyCompany.builder()
    .name("Acme Widgets, Inc.")
    .build();

Map<String, JsonNode> traits = new HashMap<>();
traits.put("city", ObjectMappers.JSON_MAPPER.valueToTree("Atlanta"));
traits.put("high_score", ObjectMappers.JSON_MAPPER.valueToTree(25));
traits.put("is_active", ObjectMappers.JSON_MAPPER.valueToTree(true));

schematic.identify(keys, company, "Wile E. Coyote", traits);

This call is non-blocking and there is no response to check.

Sending track events

Track activity in your application using track events; these events can later be used to produce metrics for targeting.

import com.schematic.api.Schematic;
import java.util.HashMap;
import java.util.Map;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .build();

Map<String, String> company = new HashMap<>();
company.put("id", "your-company-id");

Map<String, String> user = new HashMap<>();
user.put("user_id", "your-user-id");

Map<String, Object> traits = new HashMap<>();

schematic.track("some-action", company, user, traits);

This call is non-blocking and there is no response to check.

If you want to record large numbers of the same event at once, or perhaps measure usage in terms of a unit like tokens or memory, you can optionally specify a quantity for your event:

schematic.track("some-action", company, user, traits, 10);

Creating and updating companies

Although it is faster to create companies and users via identify events, if you need to handle a response, you can use the companies API to upsert companies. Because you use your own identifiers to identify companies, rather than a Schematic company ID, creating and updating companies are both done via the same upsert operation:

import com.fasterxml.jackson.databind.JsonNode;
import com.schematic.api.Schematic;
import com.schematic.api.core.ObjectMappers;
import com.schematic.api.types.UpsertCompanyRequestBody;
import java.util.HashMap;
import java.util.Map;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .build();

Map<String, String> keys = new HashMap<>();
keys.put("id", "your-company-id");

Map<String, JsonNode> traits = new HashMap<>();
traits.put("city", ObjectMappers.JSON_MAPPER.valueToTree("Atlanta"));
traits.put("high_score", ObjectMappers.JSON_MAPPER.valueToTree(25));
traits.put("is_active", ObjectMappers.JSON_MAPPER.valueToTree(true));

UpsertCompanyRequestBody request = UpsertCompanyRequestBody.builder()
    .keys(keys)
    .name("Acme Widgets, Inc.")
    .traits(traits)
    .build();

var response = schematic.companies().upsertCompany(request);
System.out.println("Company upserted: " + response.getData().getName());

Checking flags

When checking a flag, you'll provide keys for a company and/or keys for a user. You can also provide no keys at all, in which case you'll get the default value for the flag.

import com.schematic.api.Schematic;
import java.util.HashMap;
import java.util.Map;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .build();

Map<String, String> company = new HashMap<>();
company.put("id", "your-company-id");

Map<String, String> user = new HashMap<>();
user.put("user_id", "your-user-id");

boolean flagValue = schematic.checkFlag("some-flag-key", company, user);

checkFlagWithEntitlement answers the same question and hands back the whole result: the value, the reason the rules engine gave, and the matched entitlement.

Credit Leases and Reservations

For features metered by credit burndown (inference tokens, for example), check reserves credits for the work about to run and trackWithReservation settles the reservation with the actual usage. The SDK gates in one of two modes:

  • Client mode acquires a lease, a tranche of credits held against the company's balance, and carves a per-request reservation out of it locally, so a check needs no API call. It requires DataStream (or Replicator Mode) and, across multiple processes, a shared Redis so every instance gates against the same lease.
  • Server mode makes one check-and-reserve API call per check. No lease, no Redis, no local state.

mode defaults to AUTO: client when DataStream is enabled, server otherwise. Client mode suits high-throughput gating; server mode suits low-volume checks and operations that run for seconds.

Setup

import com.schematic.api.Schematic;
import com.schematic.api.credits.CreditLeaseConfig;
import com.schematic.api.datastream.DatastreamOptions;
import java.time.Duration;
import redis.clients.jedis.JedisPooled;

JedisPooled redisClient = new JedisPooled("localhost", 6379);

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .datastreamOptions(DatastreamOptions.builder().build())
    .creditLeases(CreditLeaseConfig.builder()
        .defaultLeaseSize(10000)                            // credits requested per lease
        .defaultLeaseDuration(Duration.ofMinutes(5))        // lease lifetime
        .defaultReservationTtl(Duration.ofSeconds(60))      // how long a reservation is held if no track settles it
        .redisClient(redisClient)                           // lease and reservation state
        .build())
    .build();

Leases reuse the Redis client the DataStream cache is configured with, if there is one. The example above configures DataStream without a Redis cache, so it passes redisClient explicitly. Set it whenever the DataStream cache is local, or when lease state should live in a different Redis from the cache. With no Redis on either side the SDK falls back to per-process in-memory state, which gates one process only and warns at startup.

Server mode needs only a TTL:

import com.schematic.api.Schematic;
import com.schematic.api.credits.CreditLeaseConfig;
import java.time.Duration;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .creditLeases(CreditLeaseConfig.builder()
        .defaultReservationTtl(Duration.ofSeconds(60))      // just under an hour at most, which is as far out as the API will reserve credits
        .build())
    .build();

Only mode and defaultReservationTtl apply in server mode; the client warns at startup if a client-only option is set.

Checking and tracking

import com.schematic.api.credits.CheckOptions;
import com.schematic.api.credits.CheckResult;
import java.util.HashMap;
import java.util.Map;

Map<String, String> company = new HashMap<>();
company.put("id", "your-company-id");

// Reserve up to maxTokens for this operation.
CheckResult result = schematic.check("inference", company, null, CheckOptions.builder()
    .usage(maxTokens)                      // upper bound for this operation
    .eventSubtype("inference_tokens")      // the metered event
    .build());
if (!result.isAllowed()) {
    throw new IllegalStateException("credit balance exceeded");
}

long tokensUsed = runInference();

// Report the actual usage; the unused slice of the reservation is refunded.
if (result.getReservation() != null) {
    schematic.trackWithReservation(result.getReservation(), tokensUsed);
} else {
    schematic.track("inference_tokens", company, null, null, tokensUsed);
}

A check can allow without reserving credits, when the feature is not credit-metered, when usage is 0, or when the check failed open, and that usage still has to be tracked.

usage may be fractional, but credits are always sized in whole event units: a client-mode reservation records the fractional quantity, while the credits it reserves and the debit its settle makes are both ceil(usage) x consumption rate, so the local ledger moves by exactly what the track event bills. The integer fields on the wire round up for the same reason: the preflight quantity and the quantity a track event bills, so a partial unit is never billed as none.

usage still gates a check that reserves nothing: it is sent as a preflight, locally or to the API, so the verdict accounts for what the call is about to spend. Preflighted verdicts are not cached.

CheckOptions.timeout bounds every call a check waits on: the check-and-reserve call in server mode, the REST flag check a check can fall back to, and the client-mode lease acquire and extend. Lease calls are shared between concurrent checks, and a check that joins one somebody else opened waits no longer than its own timeout before giving up and taking its failure path, leaving that call running for the checks still on it. Background top-ups keep the client's own timeout.

An unsettled reservation expires after defaultReservationTtl and its credits return to the lease. A late settle still bills the usage, since the track event carries a deterministic idempotency key that keeps it from double-billing, but it does not re-debit the local lease. Set defaultReservationTtl above the longest expected gap between the check and the settle.

Pre-warming

Warm leases when the user is identified, so a session's first check does not wait on a lease acquire:

import com.schematic.api.IdentifyOptions;
import com.schematic.api.types.EventBodyIdentifyCompany;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

Map<String, String> userKeys = new HashMap<>();
userKeys.put("user_id", "your-user-id");

Map<String, String> companyKeys = new HashMap<>();
companyKeys.put("id", "your-company-id");

schematic.identify(
    userKeys,
    EventBodyIdentifyCompany.builder().keys(companyKeys).build(),
    "Your User",
    null,
    IdentifyOptions.builder()
        .prewarm(Collections.singletonList("credit-type-id"))
        .build());

Identifying with a prewarm flushes the event buffer first, so the server has the company before the warm-up asks for a lease against it. That makes it a session-start call, not one to put on every event.

Or call schematic.prewarm(companyKeys, creditTypeIds) directly. Both are no-ops in server mode.

Pre-warming resolves the company the way the server does: it looks the keys up first, whatever they are named, and only when nothing matches does it read a value carrying Schematic's comp_ prefix as the company id.

Failure behavior

In server mode, a check that times out after the server has already reserved leaves those credits reserved until the TTL expires, so keep defaultReservationTtl short there.

A check that cannot gate, because the API is unreachable, Redis is down, or the lease is exhausted, fails closed by default. Override it per check:

import com.schematic.api.credits.CheckOptions;
import com.schematic.api.credits.OnAcquireFailure;

CheckOptions options = CheckOptions.builder()
    .usage(maxTokens)
    .eventSubtype("inference_tokens")
    .onAcquireFailure(OnAcquireFailure.FAIL_OPEN)
    .build();

In client mode FAIL_OPEN still evaluates the flag's rules with the credit balance assumed sufficient, so plan targeting and every non-credit condition apply and only the credit gate is bypassed. In server mode it returns the flag's default value, which is false unless the check passes defaultValue or the client configures a flag default.

See Credit Lease Options for the full set of options.

Webhook Verification

Schematic can send webhooks to notify your application of events. To ensure the security of these webhooks, Schematic signs each request using HMAC-SHA256. The Java SDK provides utility functions to verify these signatures.

Verifying Webhook Signatures

When your application receives a webhook request from Schematic, you should verify its signature to ensure it's authentic:

import com.schematic.webhook.WebhookVerifier;
import com.schematic.webhook.WebhookSignatureException;
import java.util.Map;
import java.util.HashMap;
import java.io.BufferedReader;
import java.io.IOException;

// In your webhook endpoint handler:
public void handleWebhook(HttpServletRequest request, HttpServletResponse response) throws IOException {
    // Read the request body
    String body = request.getReader().lines().collect(Collectors.joining("\n"));

    // Get the required headers
    Map<String, String> headers = new HashMap<>();
    headers.put(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER,
                request.getHeader(WebhookVerifier.WEBHOOK_SIGNATURE_HEADER));
    headers.put(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER,
                request.getHeader(WebhookVerifier.WEBHOOK_TIMESTAMP_HEADER));

    String webhookSecret = "your-webhook-secret";

    try {
        // Verify the webhook signature
        WebhookVerifier.verifyWebhookSignature(body, headers, webhookSecret);

        // Process the webhook payload
        // ...

        response.setStatus(HttpServletResponse.SC_OK);
    } catch (WebhookSignatureException e) {
        // Handle signature verification failure
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Invalid signature: " + e.getMessage());
    }
}

Verifying Signatures Manually

If you need to verify a webhook signature outside of the context of a servlet request, you can use the verifySignature method:

import com.schematic.webhook.WebhookVerifier;
import com.schematic.webhook.WebhookSignatureException;

public void verifyWebhookManually(String body, String signature, String timestamp, String secret) {
    try {
        WebhookVerifier.verifySignature(body, signature, timestamp, secret);
        System.out.println("Signature verification successful!");
    } catch (WebhookSignatureException e) {
        System.out.println("Signature verification failed: " + e.getMessage());
    }
}

Configuration Options

There are a number of configuration options that can be specified using the builder when instantiating the Schematic client.

Flag Check Options

By default, the client will do some local caching for flag checks. If you would like to change this behavior, you can do so using initialization options to specify the cache providers:

import com.schematic.api.Schematic;
import com.schematic.api.cache.LocalCache;
import java.time.Duration;
import java.util.Collections;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .cacheProviders(Collections.singletonList(new LocalCache<>()))
    .build();

You can also disable local caching entirely; bear in mind that, in this case, every flag check will result in a network request:

import com.schematic.api.Schematic;
import java.util.Collections;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .cacheProviders(Collections.emptyList())
    .build();

You may want to specify default flag values for your application, which will be used if there is a service interruption or if the client is running in offline mode:

import com.schematic.api.Schematic;
import java.util.HashMap;
import java.util.Map;

Map<String, Boolean> flagDefaults = new HashMap<>();
flagDefaults.put("some-flag-key", true);

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .flagDefaults(flagDefaults)
    .build();

Credit Lease Options

Set with creditLeases(CreditLeaseConfig.builder()...build()). Per-credit-type overrides take a CreditLeaseOverride under override(creditTypeId, ...).

Option Type Default Description
mode CreditLeaseMode AUTO Where credits are reserved; AUTO picks client when DataStream is enabled, server otherwise
defaultReservationTtl Duration 60 seconds How long an unsettled reservation is held
defaultLeaseDuration Duration 5 minutes (client mode) Lease lifetime
defaultLeaseSize double 10000 (client mode) Credits requested per lease acquire or extend
lowWaterMark double 0.25 (client mode) Extend in the background when the lease balance dips below this fraction
sweepInterval Duration 1 second (client mode) How often expired reservations are swept
prewarmResolveTimeout Duration 5 seconds (client mode) How long prewarm waits for a freshly identified company to surface; zero resolves from the DataStream cache only
redisClient JedisPooled the DataStream cache's client (client mode) Redis client for lease and reservation state
redisKeyPrefix String the DataStream cache's prefix (client mode) Key prefix for lease and reservation keys
overrides Map<String, CreditLeaseOverride> none (client mode) Per-credit-type overrides of defaultLeaseDuration, defaultReservationTtl, defaultLeaseSize and lowWaterMark, keyed by credit type id

Offline Mode

In development or testing environments, you may want to avoid making network requests when checking flags or submitting events. You can run Schematic in offline mode:

import com.schematic.api.Schematic;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .offline(true)
    .build();

When in offline mode:

  1. Flag checks will return the default value for the flag being checked (false by default, or as specified in flagDefaults)
  2. Events (identify and track) will be skipped completely
  3. All other API calls will use a no-op HTTP client that doesn't make actual network requests, returning empty responses

This is especially useful for development, testing, or when running unit tests that shouldn't depend on the Schematic API.

Offline mode works well with flag defaults:

import com.schematic.api.Schematic;
import java.util.HashMap;
import java.util.Map;

Map<String, Boolean> flagDefaults = new HashMap<>();
flagDefaults.put("some-flag-key", true);

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .offline(true)
    .flagDefaults(flagDefaults)
    .build();

boolean flagValue = schematic.checkFlag("some-flag-key", null, null); // Returns true

Event Buffer

Schematic API uses an Event Buffer to batch Identify and Track requests and avoid multiple API calls. You can set the event buffer flush period:

import com.schematic.api.Schematic;
import java.time.Duration;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .eventBufferInterval(Duration.ofSeconds(5))
    .build();

Exception Handling

When the API returns a non-success status code (4xx or 5xx response), a subclass of SchematicException will be thrown.

import com.schematic.api.core.SchematicException;

try {
    schematic.companies().getCompany(...);
} catch (SchematicException e) {
    System.out.println(e.message());
}

The SDK also supports error handling for first class exceptions with strongly typed body fields.

import com.schematic.api.errors.InvalidRequestError;

try {
    schematic.companies().getCompany(...);
} catch (InvalidRequestError e) {
    System.out.println(e.message());
    System.out.println(e.getBody().getMissingField());
}

Retries

The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long as the request is deemed retriable and the number of retry attempts has not grown larger than the configured retry limit (default: 2).

A request is deemed retriable when any of the following HTTP status codes is returned:

  • 408 (Timeout)
  • 429 (Too Many Requests)
  • 5XX (Internal Server Errors)

DataStream

DataStream enables local flag evaluation by maintaining a WebSocket connection to Schematic and caching flag rules, company, and user data locally. This reduces latency and network calls for flag checks.

Key Features

  • Real-Time Updates: Automatically updates cached data when changes occur on the backend.
  • Configurable Caching: Supports both in-memory (local) caching and Redis-based caching.
  • Efficient Flag Checks: Flag evaluation happens locally using a WASM rules engine.

Setup

import com.schematic.api.Schematic;
import com.schematic.api.datastream.DatastreamOptions;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .datastreamOptions(DatastreamOptions.builder()
        .build())
    .build();

// Flag checks are now evaluated locally
boolean flagValue = schematic.checkFlag("some-flag-key", company, user);

// When done, close the client to release resources
schematic.close();

Configuration Options

Option Type Default Description
cacheTTL Duration 24 hours Cache TTL for flag/company/user data
redisCache RedisCacheConfig — Redis connection config (uses in-memory cache if not provided)

Configuring Redis Cache

DataStream supports Redis for caching, which is required for Replicator Mode. Pass a RedisCacheConfig and the SDK will create and manage the Redis connection internally:

import com.schematic.api.Schematic;
import com.schematic.api.cache.RedisCacheConfig;
import com.schematic.api.datastream.DatastreamOptions;
import java.time.Duration;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .datastreamOptions(DatastreamOptions.builder()
        .redisCache(RedisCacheConfig.builder()
            .endpoint("localhost:6379")
            .keyPrefix("schematic:")
            .build())
        .cacheTTL(Duration.ofMinutes(5))
        .build())
    .build();

Redis Configuration Options

Option Type Default Description
endpoint String localhost:6379 Redis server address in host:port format
endpoints List<String> ["localhost:6379"] Multiple endpoints (for future cluster support)
username String — Redis 6.0+ ACL username
password String — Redis password
database int 0 Redis database index
ssl boolean false Enable SSL/TLS
keyPrefix String schematic: Prefix for all Redis cache keys
connectTimeout Duration 5 seconds Connection timeout
readTimeout Duration 3 seconds Read timeout
maxPoolSize int 8 Maximum connection pool size

Replicator Mode

Replicator mode is designed for environments where a separate process (the schematic-datastream-replicator) manages the WebSocket connection and populates a shared Redis cache. The SDK reads from that cache and evaluates flags locally without establishing its own WebSocket connection.

Requirements

Replicator mode requires Redis as a shared cache so the SDK can read data written by the external replicator process. An in-memory cache will not work since the replicator and SDK run in separate processes.

Setup

import com.schematic.api.Schematic;
import com.schematic.api.cache.RedisCacheConfig;
import com.schematic.api.datastream.DatastreamOptions;

Schematic schematic = Schematic.builder()
    .apiKey("YOUR_API_KEY")
    .datastreamOptions(DatastreamOptions.builder()
        .redisCache(RedisCacheConfig.builder()
            .endpoint("localhost:6379")
            .build())
        .withReplicatorMode("http://localhost:8090/ready")
        .build())
    .build();

Configuration Options

Option Type Default Description
withReplicatorMode String — Enables replicator mode with the given health check URL
redisCache RedisCacheConfig — Required. Redis connection config for the shared cache
replicatorHealthCheckInterval Duration 30 seconds Health check polling interval
cacheTTL Duration 24 hours Cache TTL (should match the replicator's TTL)

Cache TTL Configuration

Important: When using Replicator Mode, you should set the SDK's cache TTL to match the replicator's cache TTL. The replicator defaults to an unlimited cache TTL. If the SDK uses a shorter TTL (the default is 24 hours), locally updated cache entries (e.g. after track events) will be written back with the shorter TTL and eventually evicted from the shared Redis cache, even though the replicator originally set them with no expiration.

To match the replicator's default unlimited TTL:

DatastreamOptions.builder()
    .redisCache(RedisCacheConfig.builder()
        .endpoint("localhost:6379")
        .build())
    .withReplicatorMode("http://localhost:8090/ready")
    .cacheTTL(Duration.ZERO) // Unlimited, matching the replicator default
    .build()

When running in Replicator Mode, the client will:

  • Skip establishing WebSocket connections
  • Periodically check if the replicator service is ready
  • Use cached data populated by the external replicator service
  • Fall back to direct API calls if the replicator is not available

Contributing

While we value open-source contributions to this SDK, this library is generated programmatically. Additions made directly to this library would have to be moved over to our generation code, otherwise they would be overwritten upon the next generated release. Feel free to open a PR as a proof of concept, but know that we will not be able to merge it as-is. We suggest opening an issue first to discuss with us!

On the other hand, contributions to the README are always very welcome!

About

Schematic Java SDK

Resources

Contributing

Stars

0 stars

Watchers

6 watching

Forks

Releases

Packages

Used by

Contributors

Languages