> If you are setting up, configuring, or answering questions about Schematic, connect the
> Schematic MCP server before working from these pages. It reads and writes plans, features,
> entitlements, and overrides against the real account, so what you report and what you build
> match what actually exists.
>
> The server is at https://api.schematichq.com/mcp
>
> From Claude, ChatGPT, or another chat assistant, add it as a custom connector:
> https://docs.schematichq.com/working-with-ai
>
> From a coding assistant such as Claude Code, Codex, or Cursor:
> https://docs.schematichq.com/building-with-ai
>
> For clean Markdown of any page, append `.md` to the page URL. For a complete page index,
> see https://docs.schematichq.com/llms.txt

# Go

> Use schematic-go to easily implement pricing and packaging in your go app with Schematic.

## Installation and Setup

1. Install the Go library:

```bash
go get github.com/schematichq/schematic-go
```

2. [Issue an API key](/quickstart/account-setup#2-create-your-api-keys) for the appropriate environment using the [Schematic app](https://app.schematichq.com/settings/api-keys). Be sure to capture the secret key when you issue the API key; you'll only see this key once, and this is what you'll use with the Schematic Go library.

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

```go
import (
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()
}
```

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 an initialization option to specify the max size of the cache (in terms of number of records) and the max age of the cache (as a `time.Duration`):

```go
import (
  "os"
  "time"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  cacheSize := 100
  cacheTTL := 1 * time.Millisecond
  client := schematicclient.NewSchematicClient(
    option.WithAPIKey(apiKey),
    option.WithLocalFlagCheckCache(cacheSize, cacheTTL),
  )
  defer client.Close()
}
```

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

```go
import (
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey), option.WithDisabledFlagCheckCache())
  defer client.Close()
}
```

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 (see below). You can do this using an initialization option:

```go
import (
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey), option.WithDefaultFlagValues(map[string]bool{
    "some-flag-key": true,
  }))
  defer client.Close()
}
```

## Usage examples

A number of these examples use `keys` to identify companies and users. Learn more about keys [here](/developer_resources/key_management).

### Sending identify events

Create or update users and companies using identify events.

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  client.Identify(context.Background(), &schematicgo.EventBodyIdentify{
    Event: "some-action",
    Company: map[string]string{
      "id": "your-company-id",
    },
    User: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
    Name: "Wile E. Coyote",
    Traits: map[string]any{
      "city":        "Atlanta",
      "login_count": 24,
      "is_staff":    false,
    },
  })
}
```

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.

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  client.Track(context.Background(), &schematicgo.EventBodyTrack{
    Event: "some-action",
    Company: map[string]string{
      "id": "your-company-id",
    },
    User: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
  })
}
```

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:

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  client.Track(context.Background(), &schematicgo.EventBodyTrack{
    Event: "query-tokens",
    Company: map[string]string{
      "id": "your-company-id",
    },
    User: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
    Quantity: schematicgo.Int(1500),
  })
}
```

### 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:

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  body := &schematicgo.UpsertCompanyRequestBody{
    Keys: map[string]string{
      "id": "your-company-id",
    },
    Name: "Acme Widgets, Inc.",
    Traits: map[string]any{
      "city":       "Atlanta",
      "high_score": 25,
      "is_active":  true,
    },
  })
  resp, err := client.API().Companies.UpsertCompany(context.Background, body)
}
```

You can define any number of company keys; these are used to address the company in the future, for example by updating the company's traits or checking a flag for the company.
You can also define any number of company traits; these can then be used as targeting parameters.

### Creating and updating users

Similarly, you can upsert users using the Schematic API, as an alternative to using identify events. Because you use your own identifiers to identify users, rather than a Schematic user ID, creating and updating users are both done via the same upsert operation:

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/SchematicHQ/schematic-go/client"
  schematicgo "github.com/SchematicHQ/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  body := &schematicgo.UpsertUserRequestBody{
    Keys: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
    Company: map[string]string{
      "id": "your-company-id",
    },
    Name: "Wile E. Coyote",
    Traits: map[string]any{
      "city":        "Atlanta",
      "login_count": 24,
      "is_staff":    false,
    },
  })

  resp, err := client.API().Companies.UpsertUser(context.Background(), body)
}
```

You can define any number of user keys; these are used to address the user in the future, for example by updating the user's traits or checking a flag for the user.
You can also define any number of user traits; these can then be used as targeting parameters.

### 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.

```go
import (
  "context"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  evaluationCtx := schematicgo.CheckFlagRequestBody{
    Company: map[string]string{
      "id": "your-company-id",
    },
    User: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
  }

  if client.CheckFlag(context.Background(), "some-flag-key", evaluationCtx) {
    // Flag is on
  } else {
    // Flag is off
  }
}
```

### Checking flags with entitlement details

If you need more detail about how a flag check was resolved, including any entitlement associated with the check, use `CheckFlagWithEntitlement`. This returns a response object with the flag value, the reason for the evaluation result, and entitlement details such as usage, allocation, and credit balances when applicable.

```go
import (
  "context"
  "fmt"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  evaluationCtx := &schematicgo.CheckFlagRequestBody{
    Company: map[string]string{
      "id": "your-company-id",
    },
    User: map[string]string{
      "email":   "wcoyote@acme.net",
      "user-id": "your-user-id",
    },
  }

  resp, err := client.CheckFlagWithEntitlement(context.Background(), evaluationCtx, "some-flag-key")
  if err != nil {
    fmt.Printf("Error: %v\n", err)
    return
  }

  fmt.Printf("Flag: %s, Value: %v, Reason: %s\n", resp.FlagKey, resp.Value, resp.Reason)

  if resp.Entitlement != nil {
    fmt.Printf("Entitlement type: %s\n", resp.Entitlement.ValueType)
    if resp.Entitlement.Usage != nil && resp.Entitlement.Allocation != nil {
      fmt.Printf("Usage: %d\n", *resp.Entitlement.Usage)
    }
    if resp.Entitlement.CreditRemaining != nil {
      fmt.Printf("Credit remaining: %.2f\n", *resp.Entitlement.CreditRemaining)
    }
  }
}
```

#### Preflight

To ask whether an action *would* be allowed before performing it, set `Preflight` on the evaluation context. The usage is applied to any numeric condition the flag evaluates, so a company one call short of its limit comes back false:

```go
evaluationCtx := &schematicgo.CheckFlagRequestBody{
  Company: map[string]string{
    "id": "your-company-id",
  },
  Preflight: &schematicgo.PreflightRequestBody{
    Usage: schematicgo.Int64(100),
  },
}

resp, err := client.CheckFlagWithEntitlement(context.Background(), evaluationCtx, "some-flag-key")
```

Use `EventUsage` instead of `Usage` when you know which event the usage will be recorded under, so only conditions measuring that subtype move:

```go
Preflight: &schematicgo.PreflightRequestBody{
  EventUsage: &schematicgo.PreflightEventUsageRequestBody{
    EventSubtype: "inference_tokens",
    Quantity:     1000,
  },
}
```

Only the flag value reflects the preflight; the entitlement and usage figures in the response are the company's current, unsimulated ones. Preflighted checks are never served from, or written to, the flag check cache.

### Checking multiple flags

When you need to evaluate several flags for the same company/user context, `CheckFlags` returns the full result for each requested flag in a single call. Pass `nil` or an empty slice for `keys` to retrieve every flag defined for the context.

```go
import (
  "context"
  "fmt"
  "os"

  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  evaluationCtx := &schematicgo.CheckFlagRequestBody{
    Company: map[string]string{
      "id": "your-company-id",
    },
  }

  results := client.CheckFlags(context.Background(), evaluationCtx, []string{"feature-flag-1", "feature-flag-2"})
  for _, r := range results {
    fmt.Printf("%s: %v (%s)\n", r.FlagKey, r.Value, r.Reason)
  }
}
```

Results are returned in the same order as the requested keys. When any requested key is a cache miss, `CheckFlags` refreshes the full set from the API so every returned value comes from a consistent evaluation. If DataStream is enabled and connected, keys are evaluated locally — any failure causes a transparent fallback to the API. In offline mode, each key resolves to its configured default.

### Other API operations

The Schematic API supports many operations beyond these, accessible via `client.API()`. See the [API reference](https://github.com/SchematicHQ/schematic-go/blob/main/reference.md) for a full list and documentation of supported operations.

## Testing

### Offline Mode

In development or testing environments, you may want to avoid making network requests to the Schematic API. You can run Schematic in offline mode by not providing an API key to the client:

```go
import (
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  client := schematicclient.NewSchematicClient()
  defer client.Close()
}
```

You can also enable offline mode by providing an empty API key:

```go
import (
  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  client := schematicclient.NewSchematicClient(option.WithAPIKey(""))
  defer client.Close()
}
```

Or, by using the offline mode option:

```go
import (
  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  client := schematicclient.NewSchematicClient(option.WithOfflineMode())
  defer client.Close()
}
```

Offline mode works well with flag defaults:

```go
import (
  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func main() {
  client := schematicclient.NewSchematicClient(option.WithOfflineMode(), option.WithDefaultFlagValues(map[string]bool{
    "some-flag-key": true,
  }))
  defer client.Close()
}
```

In an automated testing context, you may also want to use offline mode and specify single flag responses for test cases:

```go
import (
  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
)

func TestSomeFunctionality(t *testing.T) {
  client := schematicclient.NewSchematicClient(option.WithOfflineMode())
  defer client.Close()

  client.SetFlagDefault("some-flag-key", true)

  // test code that expects the flag to be on
}
```

## 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 Go 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. The SDK provides a simple function to verify webhook signatures:

```go
import (
  "net/http"

  "github.com/schematichq/schematic-go/webhooks"
)

func yourWebhookHandler(w http.ResponseWriter, r *http.Request) {
  // Each webhook has a distinct secret; you can access this via the Schematic app
  webhookSecret := "your-webhook-secret"

  // Verify the webhook signature
  // The request body is preserved for later reading
  err := webhooks.VerifyWebhookSignature(r, webhookSecret)
  if err != nil {
    // The signature is invalid or there was another error
    http.Error(w, "Invalid signature", http.StatusBadRequest)
    return
  }

  // The signature is valid, process the webhook...
}
```

If you want to verify a webhook signature outside of the context of a web request, you can also do that using the `webhooks.VerifySignature` function.

## DataStream

The `DataStream` functionality in Schematic provides real-time updates for flags, companies, and users. It uses WebSocket connections to receive updates from the Schematic backend and caches the data locally or in Redis for efficient access.

### 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 reducing number of network calls.

---

### How to Enable DataStream

To enable the `DataStream` functionality, you need to pass the `WithDatastream` option when creating a new `SchematicClient`.

#### Example:

```go
import (
	schematicclient "github.com/schematichq/schematic-go/client"
	schematicoption "github.com/schematichq/schematic-go/option"
)

func main() {
	apiKey := "your-api-key"

	options := &core.DatastreamOptions{
		CacheTTL: 5 * time.Minute, // Set the cache TTL (time-to-live)
	}

	client := schematicclient.NewSchematicClient(
		schematicoption.WithAPIKey(apiKey),
		schematicoption.WithDatastream(options),
	)

  defer client.Close()
}
```

---

### Configuring Redis Cache

The `DataStream` functionality supports Redis for caching company and user data. You can configure it to use either a single Redis instance or a Redis cluster.

#### 1. Single Redis Instance

To use a single Redis instance, create a `RedisCacheConfig` and pass it to the `DatastreamOptions`.

#### Example:

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute, // Set the cache TTL
	CacheConfig: &schematicoption.RedisCacheConfig{
		Addr:         "localhost:6379", // Redis server address
		Password:     "",              // Redis password (if any)
		DB:           0,               // Redis database index
		MaxRetries:   3,               // Number of retries for failed operations
		DialTimeout:  5 * time.Second, // Connection timeout
		ReadTimeout:  3 * time.Second, // Read timeout
		WriteTimeout: 3 * time.Second, // Write timeout
	},
}
```

`Addr` also accepts a connection URL, or you can set `URL` to be explicit. A `rediss://`
URL enables TLS with the server name taken from the host, so no `TLSConfig` is needed:

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute,
	CacheConfig: &schematicoption.RedisCacheConfig{
		URL:         "rediss://user:password@cache.example.com:6379/0",
		DialTimeout: 5 * time.Second, // Fields set here override what the URL derived
	},
}
```

A URL that does not parse, or that names no host, is logged as a warning and the
datastream falls back to its local cache, rather than dialing an address you did not
name. Credentials are redacted from that warning, and the underlying parse error is
withheld from it entirely when the URL carries any — `net/url` quotes both the URL and
fragments of it into its errors, so an unescaped `/` in a password ends up quoted back
as an "invalid port".

Fields set on the struct override what the URL derived, but only when they are non-zero,
so a URL-derived setting cannot be switched back off here: drop it from the URL instead.

To enable TLS against a plain `host:port` address, set `TLSConfig` yourself. An
otherwise-empty config keeps hostname verification on and uses the system roots, which
is what a publicly-trusted certificate (ElastiCache, for instance) needs:

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute,
	CacheConfig: &schematicoption.RedisCacheConfig{
		Addr:      "cache.example.com:6379",
		TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
	},
}
```

#### 2. Redis Cluster

To use a Redis cluster, create a `RedisCacheClusterConfig` and pass it to the `DatastreamOptions`.

#### Example:

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute, // Set the cache TTL
	CacheConfig: &schematicoption.RedisCacheClusterConfig{
		Addrs:            []string{"localhost:7000", "localhost:7001", "localhost:7002"}, // Cluster node addresses
		Password:         "",              // Redis password (if any)
		MaxRetries:       3,               // Number of retries for failed operations
		DialTimeout:      5 * time.Second, // Connection timeout
		ReadTimeout:      3 * time.Second, // Read timeout
		WriteTimeout:     3 * time.Second, // Write timeout
		RouteByLatency:   true,            // Route requests to the node with the lowest latency
		RouteRandomly:    false,           // Disable random routing
	},
}
```

A cluster accepts a connection URL on the same terms as a single instance — its
configuration endpoint, typically. Set `URL`, or leave a single scheme-bearing entry in
`Addrs`; name any further nodes with go-redis' `?addr=` parameter, or as bare `host:port`
entries beside the URL:

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute,
	CacheConfig: &schematicoption.RedisCacheClusterConfig{
		URL: "rediss://user:password@clustercfg.example.com:6379?addr=node2.example.com:6379",
	},
}
```

`URL` takes precedence over `Addrs`, which is then ignored. Two scheme-bearing entries in
`Addrs` is an error — each would carry its own credentials and TLS, with nothing to say
which the cluster should use — and, like a URL that does not parse or names no host, it
leaves the datastream on its local cache with a warning.

For a cluster that requires TLS — an encrypted ElastiCache or Valkey cluster, say — set
`TLSConfig`. It applies to every node, so seed `Addrs` with the cluster's configuration
endpoint: slot discovery then returns hostnames the certificate covers.

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL: 5 * time.Minute,
	CacheConfig: &schematicoption.RedisCacheClusterConfig{
		Addrs:     []string{"cache.example.com:6379"},
		TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
	},
}
```

#### 3. Bringing your own client

If your application already builds a Redis client, hand it over whole rather than
restating its settings field by field. The configs above map a fixed set of go-redis
options; a client passed this way keeps everything, including what those configs
cannot express — a custom dialer, hooks, or a tracing wrapper.

```go
options := &schematicoption.DatastreamOptions{
	CacheTTL:    5 * time.Minute,
	CacheConfig: schematicoption.WithRedisClient(myRedisClient), // any redis.UniversalClient
}
```

The SDK shares the client across all of its caches and never closes it, so its
lifecycle stays yours.

---

### Replicator Mode

When running the `schematic-datastream-replicator` service, you should configure the Schematic client to operate in **Replicator Mode**. In this mode, the client connects to the external replicator service instead of establishing its own WebSocket connections, and the replicator handles all data streaming and caching automatically.

#### How to Enable Replicator Mode

To enable Replicator Mode, simply use the `WithReplicatorMode()` option within `WithDatastream()`:

```go
import (
	schematicclient "github.com/schematichq/schematic-go/client"
	"github.com/schematichq/schematic-go/core"
)

func main() {
	apiKey := "your-api-key"

	client := schematicclient.NewSchematicClient(
		core.WithAPIKey(apiKey),
		core.WithDatastream(
			core.WithReplicatorMode(),
		),
	)

	defer client.Close()
}
```

#### Cache TTL Configuration

**Important:** When using Replicator Mode, you must set the SDK's cache TTL to match the replicator's cache TTL. The replicator defaults to an unlimited cache TTL (`0`). 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:

```go
client := schematicclient.NewSchematicClient(
	core.WithAPIKey(apiKey),
	core.WithDatastream(
		core.WithReplicatorMode(),
		core.WithCacheTTL(0), // Unlimited, matching the replicator default
	),
)
```

If you have configured a custom cache TTL on the replicator, use the same value here.

#### Advanced Configuration (Optional)

The client automatically configures sensible defaults for replicator mode, but you can customize the configuration if needed:

```go
client := schematicclient.NewSchematicClient(
	core.WithAPIKey(apiKey),
	core.WithDatastream(
		core.WithReplicatorMode(),
		core.WithCacheTTL(0), // Match the replicator's cache TTL
		core.WithReplicatorHealthURL("http://my-replicator:8090/ready"),
		core.WithReplicatorHealthInterval(60*time.Second),
	),
)
```

#### Default Configuration

* **Replicator Health URL**: `http://localhost:8090/ready`
* **Health Check Interval**: 30 seconds
* **Cache TTL**: 24 hours (SDK default; should be set to match the replicator's TTL, which defaults to unlimited)

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

## Credit Leases and Reservations

For features metered by credit burndown, such as inference tokens, `Check` reserves credits for the work you are about to do and `TrackWithReservation` settles the reservation with the actual usage. The SDK gates in one of two modes:

* **Client mode** draws a *lease*, a tranche of credits, from the server and carves a per-operation *reservation* out of it locally, so a check needs no API call. It needs [DataStream](#datastream), and on a multi-process deployment a shared Redis so every process 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`, which picks client mode when DataStream is enabled and server mode otherwise.

### Setup

```go
import (
  "time"

  "github.com/redis/go-redis/v9"
  schematicclient "github.com/schematichq/schematic-go/client"
  core "github.com/schematichq/schematic-go/core"
  option "github.com/schematichq/schematic-go/option"
)

redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithDatastream(option.WithRedisClient(redisClient)), // also backs lease state
  option.WithCreditLeases(core.CreditLeaseConfig{
    DefaultLeaseSize:      10_000,          // credits drawn per lease
    DefaultLeaseDuration:  5 * time.Minute, // how long a lease lives
    DefaultReservationTTL: time.Minute,     // how long a reservation survives unsettled
  }),
)
defer client.Close()
```

Set `RedisClient` on the config to keep lease state in a different Redis than the DataStream cache. Without any Redis, lease state stays per process, which the SDK warns about at startup, and `Close` releases what this process holds.

Server mode needs only a TTL:

```go
client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithCreditLeases(core.CreditLeaseConfig{
    DefaultReservationTTL: time.Minute, // how long the server reserves the credits if no track settles them, max 1 hour
  }),
)
```

Only `Mode` and `DefaultReservationTTL` apply in server mode; the SDK warns at startup when a client-only option is set.

### Checking and tracking

Reserve the operation's upper bound, do the work, and report what it actually used. The unspent slice returns to the balance.

```go
result := client.Check(ctx, evaluationCtx, "inference",
  schematicclient.WithUsage(1000),                      // upper bound for this operation
  schematicclient.WithEventSubtype("inference_tokens"), // the metered event
)
if !result.Allowed {
  return fmt.Errorf("credit balance exceeded: %s", result.Reason)
}

tokensUsed := runInference()

// A check can allow without reserving anything, for instance when the feature
// is not metered by credits. That usage still has to be tracked.
if result.Reservation != nil {
  client.TrackWithReservation(ctx, result.Reservation, tokensUsed)
} else {
  client.Track(ctx, &schematicgo.EventBodyTrack{
    Event:    "inference_tokens",
    Company:  map[string]string{"id": "your-company-id"},
    Quantity: schematicgo.Int64(tokensUsed),
  })
}
```

An unsettled reservation is refunded at `DefaultReservationTTL`. A settle arriving later still bills the server, since the event carries an idempotency key derived from the reservation ID, but no longer re-debits the local lease, so size the TTL above the longest expected gap between `Check` and `TrackWithReservation`.

### Pre-warming

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

```go
client.Identify(ctx, &schematicgo.EventBodyIdentify{
  Keys:    map[string]string{"user-id": "your-user-id"},
  Company: &schematicgo.EventBodyIdentifyCompany{Keys: map[string]string{"id": "your-company-id"}},
}, schematicclient.WithIdentifyPrewarm([]string{"credit-type-id"}))
```

Or call `client.Prewarm(ctx, evaluationCtx, []string{"credit-type-id"})` directly, which returns any acquire error. 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.

### When a check cannot gate

A check that cannot gate, because the API is unreachable, Redis is down, or the lease is exhausted, fails closed by default: `Allowed` is false and nothing is reserved. Pass `schematicclient.WithFailOpen()` where letting traffic through beats denying it.

In client mode, fail-open still runs 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 your default value (`schematicclient.WithCheckDefault`, else the client's flag default), which is false unless you set one. Insufficient credits is different: the server knows the credits are not there, so the check denies with reason `Insufficient credits` whatever the failure mode says.

### Configuration Options

All fields live on `core.CreditLeaseConfig`. Everything below `DefaultReservationTTL` applies to client mode only.

| Option                  | Type                                  | Default                       | Description                                                                                                            |
| ----------------------- | ------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `Mode`                  | `core.CreditLeaseMode`                | `auto`                        | `client`, `server`, or `auto` (client when DataStream is enabled).                                                     |
| `DefaultReservationTTL` | `time.Duration`                       | 1 minute                      | How long an unsettled reservation survives. Capped at one hour in server mode.                                         |
| `DefaultLeaseDuration`  | `time.Duration`                       | 5 minutes                     | Lease lifetime requested at acquire and extend.                                                                        |
| `DefaultLeaseSize`      | `float64`                             | 10000                         | Credits requested per acquire, and the minimum extend tranche.                                                         |
| `LowWaterMark`          | `float64`                             | 0.25                          | Extend in the background when the lease balance dips below this fraction.                                              |
| `SweepInterval`         | `time.Duration`                       | 1 second                      | How often expired reservations are swept back to their leases.                                                         |
| `PrewarmResolveTimeout` | `*time.Duration`                      | 5 seconds                     | How long `Prewarm` waits for a freshly identified company to surface. Zero skips the wait.                             |
| `RedisClient`           | `redis.UniversalClient`               | the DataStream cache's client | Client for lease and reservation state.                                                                                |
| `RedisKeyPrefix`        | `string`                              | `schematic:`                  | Key prefix for lease and reservation keys. Matches the Node and Python SDKs.                                           |
| `Overrides`             | `map[string]core.CreditLeaseOverride` | none                          | Per-credit-type overrides of the four knobs above, keyed by credit type ID. A nil field keeps the client-wide default. |

## OpenTelemetry

The SDK can record OpenTelemetry spans for the work it does — flag checks, identifies, tracks and credit holds. Each span sits under whichever span is already on the `context.Context` you pass in.

Spans record the *names* of the keys you look companies and users up by, never the values, so emails and internal account IDs stay out of your traces.

Tracing is off by default, since spans cost money at most vendors. Turn it on with `option.WithTracing()`, which uses the OpenTelemetry setup your application already has:

```go
client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithTracing(),
)
```

To send Schematic's spans to a particular provider instead, name it with `option.WithTracerProvider`. That turns tracing on by itself, so you do not need both:

```go
client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithTracerProvider(provider),
)
```

### HTTP requests

The SDK does not trace the HTTP requests underneath its calls. Pass a client you have already set up for tracing and it will, nested under the Schematic span that made the request:

```go
import (
  "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
  "go.opentelemetry.io/otel"
  "go.opentelemetry.io/otel/propagation"
)

// Without this, trace context is not attached to outgoing requests and your
// trace stops at your app.
otel.SetTextMapPropagator(propagation.TraceContext{})

client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithTracing(),
  option.WithHTTPClient(&http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport),
  }),
)
```

### Redis

DataStream caches and credit lease state can be stored in Redis. Pass a client you have already set up for tracing and those commands are traced the same way:

```go
client := schematicclient.NewSchematicClient(
  option.WithAPIKey(apiKey),
  option.WithTracing(),
  option.WithDatastream(option.WithRedisClient(tracedRedisClient)),
)
```

## Errors

Structured error types are returned from API calls that return non-success status codes. For example,
you can check if the error was due to an unauthorized request (i.e. status code 401) with the following:

```go
import (
  "context"
  "os"

  core "github.com/schematichq/schematic-go/core"
  option "github.com/schematichq/schematic-go/option"
  schematicclient "github.com/schematichq/schematic-go/client"
  schematicgo "github.com/schematichq/schematic-go"
)

func main() {
  apiKey := os.Getenv("SCHEMATIC_API_KEY")
  client := schematicclient.NewSchematicClient(option.WithAPIKey(apiKey))
  defer client.Close()

  body := &schematicgo.UpsertCompanyRequestBody{
    Keys: map[string]string{
      "id": "your-company-id",
    },
    Name: "Acme Widgets, Inc.",
    Traits: map[string]any{
      "city":       "Atlanta",
      "high_score": 25,
      "is_active":  true,
    },
  })
  resp, err := client.API().Companies.UpsertCompany(context.Background, body)
  if err != nil {
    if apiError, ok := err.(*core.APIError); ok {
      switch (apiError.StatusCode) {
        case http.StatusUnauthorized:
          // Do something with the unauthorized request ...
        }
      }
    return err
  }
}
```

These errors are also compatible with the `errors.Is` and `errors.As` APIs, so you can access the error
like so:

```go
if err != nil {
    var apiError *core.APIError
    if errors.As(err, apiError) {
        // Do something with the API error ...
    }
    return err
}
```