Ingestion & decisions

The decision handshake

One ETag-cached payload per session powers Switch, Config, and Pulse triggers — together. Here's how the protocol works and why we built it this way.

Most analytics SDKs evaluate feature flags by polling a single endpoint per flag, per call. That works at small scale and falls apart fast — every render becomes a network request, every flag adds latency, and there's no shared truth across products.

Sankofa's solution is the decision handshake: a single endpoint, called once per session, that returns the user's complete decision state across the products that need it — feature flags + variant assignments (Switch), typed remote config + A/B item experiments (Config), and survey triggers (Pulse). The SDK caches the response, evaluates everything locally for the rest of the session, and refreshes via ETag so unchanged state never re-hits the network.

This is the seam that makes "one binary, zero glue code" possible.

The endpoint

GET/api/v1/handshake

A decision request includes everything the engine needs to evaluate every rule against the current user:

JSON
{
"distinct_id": "user_123",
"anon_id": "anon_a3b9ff",
"default_properties": {
  "$country": "GH",
  "$os": "ios",
  "$app_version": "2.4.1"
},
"lib": "@sankofa/react-native",
"lib_version": "1.0.4",
"modules": ["switch", "config", "pulse", "abswitch"]
}

The response is the full decision payload for every module the SDK supports:

JSON
{
"etag": "W/\"7d4f...\"",
"switch": {
  "new_checkout": { "value": true, "variant": null, "reason": "rollout" },
  "dark_mode_default": { "value": false, "reason": "default" }
},
"config": {
  "max_upload_mb": { "value": 200, "version": 7, "reason": "cohort:pro" },
  "support_email": { "value": "help@sankofa.dev", "version": 12, "reason": "default" }
},
"pulse": {
  "triggers": [
    { "id": "trg_nps_30d", "rule": "session_count >= 5", "ttl_s": 600 }
  ]
},
"abswitch": {
  "checkout_redesign_test": { "variant": "treatment_b", "reason": "variant_assigned" }
}
}

When the handshake fires

  1. On app launch / cold start

    First open after install — the SDK has no cached snapshot, so it fires a handshake before the first frame. If the network is slow or unreachable, the SDK proceeds with bundled defaults.

  2. On session resume after a long background

    If sessionTimeout has elapsed since last foreground (default 30 min), the SDK rotates the session and refires the handshake.

  3. On `identify()` transition

    When a user signs in, the SDK refires the handshake with the new distinct_id so cohort-targeted decisions update immediately.

  4. On the configured refresh interval

    While the app is foregrounded, the SDK polls every 30 s with the previous response's If-None-Match: <etag> header. When nothing has changed (the common case), the engine returns 304 Not Modified and the SDK uses its cached payload — zero bandwidth past the request line.

  5. On halt-webhook trigger

    If the engine has flipped a flag's halted state since the last handshake, the SDK is push-notified (Server-Sent Events on web; long-poll on mobile). The next decision lookup uses the fresh halted state.

ETag-based refresh

This is the reason the protocol works at scale.

  1. First handshake

    Engine computes the full decision payload, hashes it into a strong ETag, returns the body + ETag: W/"7d4f..." header. The SDK caches the body and the ETag.

  2. Refresh tick

    SDK sends If-None-Match: W/"7d4f...". The engine recomputes the user's decision payload — but does not serialize it. It just compares the new payload's hash to the supplied ETag.

  3. 304 Not Modified

    If the hashes match, the engine returns 304 with no body. The SDK keeps using its cached payload. No JSON, no parse, no allocation.

  4. 200 with new body

    If the hashes differ (a flag was toggled, a config was published, the user entered a new cohort), the engine returns 200 with the fresh body and a new ETag. The SDK swaps the cache.

In normal operation, the steady-state network usage of a fully-instrumented Sankofa app is one ~200-byte 304 every 30 s — vanishingly small.

Bundled defaults

Every SDK lets you ship bundled defaults at init time:

TypeScript
Sankofa.init({
apiKey: "sk_live_...",
endpoint: "https://api.sankofa.dev",
plugins: [
  switchPlugin({
    defaults: { new_checkout: false, dark_mode_default: false },
  }),
  configPlugin({
    defaults: { max_upload_mb: 25, support_email: "help@example.com" },
  }),
],
});

Bundled defaults are returned synchronously by flags.getFlag(...) / config.get(...) even before the first handshake lands. They're also the fallback when the network is down or a key is missing in the engine's response. This is why the right pattern is: ship safe defaults in code, override on the server.

Reason tags — a stable contract

Every decision in the response includes a reason field. This is a documented stable string that the dashboard, the SDK, and your own analytics can rely on:

ReasonMeaning
defaultThe flag/config wasn't found, or no rule matched. The SDK's bundled default (or code-side default) applied.
rolloutA percentage rollout matched the user's stable bucket.
cohort:<name>A cohort-targeted rule matched (cohort:pro, cohort:internal_staff).
variant_assignedA Switch flag variant or Config A/B experiment assigned the user via stable hashing.
haltedThe flag was halted (manually or via webhook). The default applied.
dependency_unmetA flag this one depends on evaluated to off.
cohort_lookup_failedThe cohort table was unreachable. The default applied (graceful degradation).
kill_switchThe engine globally disabled this flag (admin action).

Treat reasons as a public API. They surface in the dashboard's flag-decision log, in webhook payloads, and in your own analytics if you log them.

Anon-id forwarding

The SDK sends both distinct_id and anon_id on every handshake. After identify, the anon_id is the previous anonymous ID; the engine uses both to:

  • evaluate any rule that targets users who were in the cohort before signing in;
  • emit experiment exposure events under the post-identify ID, while preserving cohort-based variant assignment from before;
  • maintain a continuous experiment arm across the login boundary.

This is why someone who got treatment_b while anonymous keeps treatment_b after signing in — the variant assignment is keyed off anon_id until distinct_id is known, then the engine forwards it.

What you don't have to think about

  • No per-flag latency — flags resolve from the cached snapshot in microseconds.
  • No race conditions — handshake and event ingest are decoupled, so a slow handshake doesn't block tracking.
  • No partial state — every product's decisions land together. You can't have stale flags but fresh configs.

What's next

Edit this page on GitHub