Token gates
Let people sign in to a brand or an app only while their wallets meet a rule of tokens, NFTs and lists.
A token gate lets someone sign in to an app only while the wallets on their account meet its rule: hold enough of a token, hold NFTs of a verified collection, hold one NFT in particular, have a wallet on a list, or any mix of those with "all of" and "any of". It applies to whole brands (every app of each) or to single apps, and an app behind several gates needs every one. Token gates need a plan that includes them (Pro and up; otherwise plan_feature_unavailable).
Making a gate
Gate sign-in on what wallets hold with:
| Field | What it is |
|---|---|
name | What the page that refuses someone calls it |
rule | Who may sign in: a tree of groups and conditions (below) |
schedule | Optional: when it applies (below) |
refusal_message | Optional: your own words, shown first when it refuses someone. Plain text, at most 500 characters and 5 lines |
brands, apps | Where it applies: brands by id, apps by client_id |
enabled | Whether it applies; on when made |
The dashboard's builder makes the same tree: Require all / any of, Add condition, Add group, with the rule in plain words as you build it, and the JSON beside it.
{
"version": 1,
"root": {
"type": "all",
"conditions": [
{
"type": "token",
"chain": "solana:mainnet",
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"min_amount": "1000",
"label": "USDC"
},
{
"type": "any",
"conditions": [
{
"type": "collection",
"chain": "solana:mainnet",
"collection": "J1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9w",
"min_count": 3
},
{
"type": "nft",
"chain": "solana:mainnet",
"asset": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"
},
{
"type": "allowlist",
"chain": "solana:mainnet",
"addresses": ["9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]
}
]
}
]
}
}That is: at least 1,000 USDC, and either three Mad Lads, Mad Lad #42 itself, or a wallet on the list.
Groups
all needs every condition in it, any at least one. Every group names its operator and holds at least one condition: there is no default, and an empty group is refused. Groups nest at most 3 deep (the top one counts), with at most 20 conditions in a gate. Nothing is ORed in for you: to let a list in whatever else the rule says, write any of the list and the rest.
Conditions
type | Fields | Met when |
|---|---|---|
token | mint, min_amount, wallets | The wallets hold at least min_amount (whole tokens, above zero) of the mint: every account they own of it, Token and Token-2022, frozen or delegated ones included, and its ZK-compressed balances. Compared exactly in the mint's base units; a minimum finer than the mint counts asks for its next unit |
collection | collection, min_count (1 to 1,000), wallets | The wallets own at least min_count NFTs whose membership of the collection is verified: Token Metadata NFTs and pNFTs, compressed NFTs and Metaplex Core assets, each counted once, burnt ones never |
nft | asset | A wallet owns that NFT (its mint, or a compressed or Core asset's id) |
allowlist | addresses (at most 1,000 in a gate) | A wallet on the account is on the list, exactly (base58 is case sensitive). Nothing is read from a chain, and the list is never shown to anyone signing in |
Every condition names its chain, as the Wallet Standard does: solana:devnet on preview, solana:mainnet in production. A condition that reads a chain must be on one Synq's API reads there (list the gates to see chains), or the request is 400. wallets says how the person's wallets count: combined (the default) adds them up, single asks one wallet to hold it all. Any condition may have a label, at most 60 characters, which the page shows in place of the token's or NFT's own name.
Synq reads every token, collection and NFT a rule names when the gate is written: a mint must be one of the Token or Token-2022 program, a collection a Core collection, a Token Metadata collection NFT or a Token-2022 group (a mint with the TokenGroup extension, whose members are the mints its member extension names: only the group's authority can make one, so the chain itself verifies them), an NFT one a person can hold (400 at its path, such as rule.root.conditions.1.collection, otherwise; 503 when the chain can't be read just now). What it read comes back as each condition's display (name, symbol, decimals, image), and each collection's collection_kind (core, token_metadata or token_group), which the refusal page shows without reading the chain again. An allowlisted address must be one a wallet can sign as: a program's address never can, and is refused.
A Token-2022 mint with the ScaledUiAmount or InterestBearing extension is compared in raw whole tokens (base units over decimals), not the amount a wallet shows.
An NFT listed in an escrow, or staked into a program that holds it, belongs to that program for as long: it doesn't count.
When it applies
schedule is a window in an IANA time zone (America/New_York, or UTC), with starts, ends or both on that zone's clocks:
- Timed (
all_day: false, the default):2026-10-10T09:00. The window ends atends: that minute is outside. A time the clocks skip (spring forward) is refused; one they pass twice (fall back) is the first of the two. - All day (
all_day: true):2026-10-10. The window runs from the start ofstartsto the end ofends, both included.
outside says what happens outside the window, and has no default since the two mean opposite things: let_in (the gate doesn't apply: holders only until launch) or refuse (the app is closed to everyone: an event, and nothing is read from a chain). A gate's answer carries its window's starts_at and ends_at in UTC and its state: upcoming, in_effect or ended.
Limits
An org has at most 100 gates. One sign-in to an app reads at most 40 conditions from the chain across every enabled gate on it (allowlists don't count); a gate that would take an app past that is 409 conflict, naming the app. Writing a gate reads what its rule names from the chain once nothing else in the request is wrong, from the same budget as your tests (429 rate_limited once it's spent for the minute). Where Synq's API reads no chain, making or turning on a gate that reads one is 409 token_gates_unavailable.
Change one: rule replaces the whole tree, schedule: null and refusal_message: null clear them, and the next sign-in or refresh at an app it applies to checks the change. Changing its rule or window, turning it on, or naming brands or apps it didn't name needs the plan; turning it off, renaming it, clearing its message or deleting it never does, and neither does sending what the gate already has (a form saved whole), so an org that moved down can still stop and clean up. A gate that's on stays enforced if the org moves to a plan without token gating: it fails closed. The dashboard keeps such an org's Token gates page, each gate read-only with Turn off and Delete.
Testing a gate
Test a token gate or a draft rule before anyone meets it: a saved gate, or a rule (and schedule) not saved yet, against up to 10 wallets, or a user's wallets in a brand. at judges the window at another time, and fresh reads the chain again. A draft is read from the chain as saving it would read it, so it is tested as the gate it would be (400 at the path of what isn't what its condition says, 503 when the chain can't say). The answer is what a sign-in would come to (result: passed, refused, unavailable, not_in_effect or closed), what each condition came to (tree, in the rule's shape, with what the wallets hold and whether each was read at all), how many reads it cost, and the refusal page's words for it (explanation). Nothing is saved.
It needs org.tokenGates.view, and org.users.view to test a user, which is recorded as user.token_gate_dry_run (a test against addresses reads only public chain data, and isn't). An org may test 30 times a minute (token_gate.dry_runs), and its tests read the chain from a budget of their own (token_gate.org_reads), so they never take from a brand's sign-ins. The dashboard's gate editor does the same under Test against wallets, with what the form holds now.
Signing in
The check comes after the person signs in and before any consent, so a sign-in through a session they already have is checked too. Their wallets are every Solana wallet on their account in the brand, each one proved by a signature when it signed in or was added; their accounts at other brands never count.
Synq reads as little as the answer needs: lists first (nothing to read), then every NFT asked about in one call, then tokens, then collections, stopping as soon as the rule is decided, and counting a collection only until its minimum. Every gate on the app is checked in one go, so a wallet two gates ask about is read once.
When the rule isn't met, the brand's page says so, gate by gate: your refusal_message first, the window if it has one, then the rule in plain words with what the person holds for each condition, the wallets that were counted (shortened), and a link to each token or NFT on an explorer of its chain. Its "Check again" reads the chain afresh (up to 10 times in 15 minutes per person), for someone who just topped up. Going back gives the app access_denied, and a request with prompt=none gets access_denied at once.
On the brand's own apps, where the brand offers wallets and linking, the page also offers "Add the wallet that holds them": the person signs in again as themselves (a fresh sign-in, since the page may follow a session someone left signed in), signs with the wallet, which joins their account, and the gates are checked again. It isn't offered while the account's email address is unverified, and another person's wallet is refused.
What can't be read (an RPC failure, a chain Synq's API doesn't read, too much read at once) is unknown, and groups combine it the way you'd expect: "any of" a condition that is met and one that can't be read is met; "all of" one that isn't met and one that can't be read is not met. Only when the answer as a whole is unknown is the sign-in refused as unavailable, with a page that says so: a gate never lets someone in on a guess.
Refreshing
Gates are checked again on every refresh, so access ends within one access token's life after the tokens leave the person's wallets, or after a window that refuses outside it closes:
- A refusal (a closed window included) ends the app's grant and answers
invalid_grant: the person signs in again, and meets the page. - An answer that can't be read answers
503temporarily_unavailableand keeps the grant, so an outage signs nobody out: retry the refresh later.
Holdings are read on Synq's server only, kept for a minute, and never shown to the app. Every check is recorded as user.token_gate_checked, with at (sign_in or refresh), each gate's result and the paths of the conditions it didn't meet or couldn't read, and never the wallets or what they hold. Making and changing gates is recorded as token_gate.created and token_gate.updated, with what the rule is made of and never an allowlist's addresses.