Convex.
Local Flag evaluation inside Queries and Mutations, with transactional Exposures.
npm install @splitch/convex@splitch/convex is the first-party Convex Component. Configuration is synced into component-private tables, so a Query can resolve a Flag with no network access, and a Mutation can put the resulting Exposure into a transactional outbox alongside your application writes. It installs @splitch/sdk, which owns the shared local evaluator.
Mount the component
// convex/convex.config.ts
import splitch from "@splitch/convex/convex.config.js";
import { defineApp } from "convex/server";
import { v } from "convex/values";
const app = defineApp({ env: { SPLITCH_API_KEY: v.string() } });
app.use(splitch, {
httpPrefix: "/integrations/splitch/",
env: { SPLITCH_API_KEY: app.env.SPLITCH_API_KEY },
});
export default app;Keep the API Key in Convex environment variables. It stays in the deployment environment and must never reach browser code. The configuration callback is served at /integrations/splitch/configuration.
On a first install, the component uses the actual CONVEX_SITE_URL, preserves the component mount path, and appends /configuration. The default *.convex.site domain and custom HTTP Actions domains both work, including when both Convex URLs use custom domains. No additional domain setting is needed.
The callback must use HTTPS on port 443 with a public DNS hostname and no credentials, query string, or fragment. IP literals, private hostnames, and Splitch's own services are rejected. Before creating a new remote installation, Splitch sends a signed challenge and requires proof that the callback holds this installation's current webhook secret. The challenge does not announce configuration or schedule a sync. Redirects are not followed.
On upgrade, install() keeps the stored callback for an active or revoked installation, and for any pending installation with a valid callback, including a custom domain. Only a new installation or repair of an invalid pending callback derives a URL. Missing or invalid CONVEX_SITE_URL fails before initialization or registration when derivation is required. An exact remote retry preserves the stored status without another challenge; a different callback or secret for the same installation ID fails.
Evaluate
Bind the component once with the Splitch class, then use it from Queries and Mutations. peekVariant and peekDetails are query-safe and fire no Exposure. evaluate and evaluateDetails are Mutation-only and queue an Exposure that is discarded if the caller's transaction rolls back.
// convex/flags.ts
import { Splitch } from "@splitch/convex";
import { v } from "convex/values";
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
const flags = new Splitch(components.splitch);
export const checkoutEnabled = query({
args: { targetingKey: v.string() },
returns: v.boolean(),
handler: (ctx, args) =>
flags.peekVariant(ctx, "new-checkout", { targetingKey: args.targetingKey }, false),
});
export const completeCheckout = mutation({
args: { targetingKey: v.string(), idempotencyKey: v.string() },
returns: v.boolean(),
handler: async (ctx, args) => {
const enabled = await flags.evaluate(
ctx,
"new-checkout",
{ targetingKey: args.targetingKey, idempotencyKey: args.idempotencyKey },
false,
);
if (typeof enabled !== "boolean") throw new Error("new-checkout must be boolean");
return enabled;
},
});Install from an Action
Call flags.install(ctx) from an Action after mounting the component, and again after upgrading @splitch/convex. The request is idempotent. On upgrade it adopts Exposure delivery work left by the previous version, resumes stale configuration sync, and schedules retention for existing rows.
// convex/setup.ts
import { Splitch } from "@splitch/convex";
import { components } from "./_generated/api";
import { action } from "./_generated/server";
const flags = new Splitch(components.splitch);
export const install = action({
args: {},
handler: (ctx) => flags.install(ctx),
});flags.sync(ctx) forces a configuration pull, flags.rotateSecret(ctx) mints a new push secret, and flags.uninstall(ctx) revokes the remote installation before purging the component's private state. All four are Action-only.
React
Component functions are private to the Convex backend, so expose an app-owned public Query that performs your authentication and derives the Evaluation Context server-side. Never take the targeting key from the client.
// convex/flags.ts
export const resolve = query({
args: { flagKey: v.string(), defaultValue: v.any() },
returns: v.any(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Authentication is required to evaluate Flags");
return flags.peekDetails(
ctx,
args.flagKey,
{ targetingKey: identity.tokenIdentifier },
args.defaultValue,
);
},
});Bind that generated Query once with createSplitchReact, then use the hooks under Convex's existing provider.
import { createSplitchReact } from "@splitch/convex/react";
import { api } from "../convex/_generated/api";
const { useFlag, useFlagDetails } = createSplitchReact(api.flags.resolve);
export function Checkout() {
const enabled = useFlag("new-checkout", false);
if (enabled === undefined) return <p>Loading…</p>;
return enabled ? <NewCheckout /> : <CurrentCheckout />;
}Both hooks preserve Convex's undefined loading state and update reactively when the synced snapshot changes. They are non-exposing reads: record the Exposure by calling flags.evaluate() in the Mutation where the Variant is actually encountered, alongside the write it controls.
Deletion and retention
flags.deleteEntity(ctx, { targetingKey, idType }) inside a Mutation removes one Entity's local holdovers and queued Exposures. Background recovery is activity-driven: the component registers no cron jobs and invokes nothing periodically while idle.
Fail-loud behavior is unchanged in the isolate. A missing credential throws at installation, and a resolution that could not be computed reports reason: "ERROR" with its errorCode rather than returning a plausible value.
splitch