# Unlock gates

> Spend primary tokens to unlock features — the non-cosmetic half of the token economy.

Source: https://docs.hatched.live/docs/guides/unlock-gates

Unlock gates are how tokens get a meaning **beyond dressing up the
buddy**. A gate is a named flag stored against a buddy; the user
"unlocks" it by spending primary tokens. Whether it guards a premium
feature, a higher difficulty, or a surprise reward is up to you.

The primitive is deliberately generic: Hatched stores the unlock, deducts
the tokens, and guarantees idempotency. The client decides what the
unlock *means*.

## Create a gate

Gates are authored in the dashboard under **Settings → Gates**.
Each gate has:

- `gate_key` — stable identifier (e.g. `advanced_mode`, `custom_skin_2`).
  Snake_case recommended.
- `token_key` — which primary token pays for it. Must match the
  customer's primary slot.
- `cost` — positive integer.
- `metadata` (optional) — arbitrary JSON returned to the client on
  lookup. Put display strings and feature flags here.

Gates live at the customer level, not per-buddy — every buddy can unlock
the same gate once.

## Unlock at runtime

```ts
import { HatchedClient, InsufficientBalanceError } from '@hatched/sdk-js';

const hatched = new HatchedClient({ apiKey: process.env.HATCHED_SECRET_KEY! });

try {
  const result = await hatched.gates.unlock(buddyId, 'advanced_mode');

  if (result.alreadyUnlocked) {
    // Idempotent — no tokens spent, existing unlock returned.
    console.log('Already unlocked at', result.unlock.unlockedAt);
  } else {
    // First unlock — tokens just got deducted.
    console.log('Unlocked for', result.gate.cost, result.gate.tokenKey);
  }
} catch (err) {
  if (err instanceof InsufficientBalanceError) {
    // User needs more tokens — show a nudge.
  } else {
    throw err;
  }
}
```

The call is **idempotent**: repeat calls return `{ alreadyUnlocked: true }`
without touching the ledger. That means you can retry safely on network
failures, and you can call `unlock()` optimistically from a UI without
double-spending.

## List a buddy's unlocks

```ts
const unlocks = await hatched.gates.unlocks(buddyId);
// [
//   { gateKey: 'advanced_mode', unlockedAt: '2026-04-20T10:00:00Z', metadata: { ... } },
// ]
```

Typical usage: fetch once on app load, cache in the client, and treat it
as the source of truth for which features to render.

## List available gates

```ts
const gates = await hatched.gates.list();
// [
//   { gateKey: 'advanced_mode', tokenKey: 'gems', cost: 50, metadata: { label: 'Advanced mode' } },
// ]
```

Use this to render a "shop" of feature unlocks alongside the marketplace.

## Publishable-key access

`gates.unlock` is scope-gated. A publishable key needs the
`write:unlocks` scope granted explicitly — it is not part of the default
scopes. That keeps browser-embedded clients from draining tokens
without intent. `gates.unlocks` and `gates.list` are read-only and
allowed under the default `read:buddies` scope.

## Gotchas

- **Primary slot only.** Gates can't spend progression tokens, by design
  (progression is monotonic). The dashboard refuses a gate pointing at
  the progression key.
- **No undo.** There's no "refund" endpoint for an accidental unlock.
  Rename the gate key if you want to effectively reset (old unlocks
  remain attached to the dead key but the client treats them as stale).
- **`alreadyUnlocked: true` is normal.** A client calling `unlock()`
  inside a `useEffect` on mount is a supported pattern — the second call
  is free.

## Related

- [Tokens](/docs/concepts/tokens) — the two-tier model that backs gate
  costs.
- [Token economy](/docs/concepts/token-economy) — how the primary slot
  fits into spending.
- [Marketplace](/docs/concepts/marketplace) — the other primary-spent
  surface.
