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:
reason | Meaning |
|---|---|
insufficientBalance | Wallet doesn’t hold enough of the token. |
gateDisabled | Gate exists but enabled: false. |
walletNotFound | Wallet 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.
mintAddressandminHoldingsare 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.