Skip to Content
ConceptsToken gates

Token gates

Gate features in your product on whether a user holds a given SPL token.

What a gate is

A rule that says “the user must hold at least N units of mint M to clear this gate.” Synq evaluates it against any wallet you pass and returns a boolean.

interface TokenGate { id: string org: string name: string description?: string mintAddress: string // SPL token mint minHoldings: string // raw token units, decimal string enabled: boolean createdAt: string updatedAt: string }

minHoldings is in raw token units, not lamports and not display units. If the token has 6 decimals, minHoldings: "1000000" requires exactly 1.0 token. (Use a decimal string because JS numbers can’t represent every supply at integer precision.)

When to use a token gate

  • Holder-only features. Premium tier, exclusive UI elements, early access.
  • Membership pricing. Free for holders, paid for non-holders.
  • Eligibility checks. “You must hold 100 $XYZ to vote.”
  • Co-marketing campaigns. Partner program where your users get benefits when they also hold partner tokens.

Don’t use them for:

  • High-frequency real-time access decisions (cache, since the gate endpoint is rate-limited).
  • Hard authorization (Synq enforces the boolean; your backend should enforce the policy).

Creating a gate

POST /identity/organizations/<orgSlug>/token-gates Authorization: Bearer <token with IdentityOrgPermission.TokenGatesEdit> Content-Type: application/json { "name": "Holder access", "description": "Active holders of $XYZ get the premium tier", "mintAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "minHoldings": "1000000", "enabled": true }

Response: the created TokenGate.

CRUD

GET /identity/organizations/<orgSlug>/token-gates GET /identity/organizations/<orgSlug>/token-gates/<gateId> PATCH /identity/organizations/<orgSlug>/token-gates/<gateId> DELETE /identity/organizations/<orgSlug>/token-gates/<gateId>

All authenticated CRUD requires IdentityOrgPermission.TokenGatesEdit (write) or .TokenGatesView (read).

Checking access — the public endpoint

GET /identity/organizations/<orgSlug>/token-gates/<gateId>/check ?walletAddress=<base58 Solana address>

No authentication required. Public endpoint.

Response:

{ "hasAccess": true, "balance": "2500000", "reason": null }

When access is denied, reason is one of:

reasonMeaning
insufficientBalanceWallet doesn’t hold enough of the token.
gateDisabledGate exists but enabled: false.
walletNotFoundWallet not in cache; try again momentarily.

Caching

Synq caches wallet balances for 5 minutes. If you need fresher data, append &refresh=true to force a fresh on-chain lookup (rate-limited per IP to prevent abuse).

For most products, the 5-minute cache is fine — a holder who just acquired the token will become eligible within minutes; one who just sold will lose eligibility within minutes.

Multi-wallet semantics

A user can have multiple linked wallets. The gate is per-wallet, so your code decides the policy:

const wallets = await synq.users.me.publicKeys.list() // Any-wallet policy async function hasAccess(gateId: string) { for (const w of wallets) { const r = await fetch( `https://synq.id/identity/organizations/${slug}/token-gates/${gateId}/check?walletAddress=${w.address}`, ) const { hasAccess } = await r.json() if (hasAccess) return true } return false } // Primary-wallet policy async function hasAccess(gateId: string) { const primary = wallets.find((w) => w.isPrimary) if (!primary) return false const r = await fetch( `https://synq.id/identity/organizations/${slug}/token-gates/${gateId}/check?walletAddress=${primary.address}`, ) return (await r.json()).hasAccess } // Sum-of-wallets policy async function hasAccess(gateId: string, minHoldings: bigint) { let total = 0n for (const w of wallets) { const r = await fetch( `https://synq.id/identity/organizations/${slug}/token-gates/${gateId}/check?walletAddress=${w.address}`, ) const { balance } = await r.json() total += BigInt(balance ?? '0') } return total >= minHoldings }

@synqid/js ships helpers for the any-wallet and primary-wallet policies; sum-of-wallets you compose yourself.

In your UI

Pre-fetch on render; show a loading state while checking; render a fallback if access denied.

function PremiumFeature({ gateId }: { gateId: string }) { const { hasAccess, isLoading } = useTokenGate(gateId, { policy: 'any-wallet' }) if (isLoading) return <Skeleton /> if (!hasAccess) return <UpgradePrompt /> return <TheActualFeature /> }

In your backend

Same call, server-side. The gate check is a public endpoint, so you don’t need auth on Synq’s side:

async function requireGate(gateId: string, walletAddress: string) { const r = await fetch( `https://synq.id/identity/organizations/${ORG}/token-gates/${gateId}/check?walletAddress=${walletAddress}`, ) const { hasAccess, reason } = await r.json() if (!hasAccess) throw new ForbiddenError(reason) } app.post('/api/premium-action', authMiddleware, async (req, res) => { await requireGate(GATE_ID, req.user.primaryWallet) // … do the thing })

Privacy notes

  • Gate IDs are part of the URL; they aren’t secret. Anyone with a gate id + a wallet address can run the check.
  • mintAddress and minHoldings are not returned in the check response, only the boolean and balance. Gate setup details are only readable by authenticated org members.
  • If your gate criteria are themselves sensitive, don’t share the gate ID with untrusted parties.

Mental model summary

  • A gate = mint + minimum holdings, per org.
  • Check via a public endpoint with a wallet address; balances cached 5 minutes.
  • Multi-wallet policy is your call. Synq tells you the per-wallet answer.
  • Advisory, not enforcing — your backend enforces the policy from the boolean.
Last updated on