Build a Solana mobile app with Synq
A complete tutorial: a fresh Expo app, Synq sign-in, Mobile Wallet Adapter wallet linking, sign-to-link against Synq, transaction signing, and a server-side webhook handler that reconciles the linked wallet into your product DB.
End-to-end, builds in about 90 minutes if you have an Expo environment set up. The result is structurally identical to what Burn & Claim and JobSkr ship.
Prerequisites: Node 22, pnpm or npm, Xcode + Android Studio if you want to run on both platforms, an Expo account for EAS Build. A Synq Org + Brand + App registered at dashboard.synq.id .
The app you will build
A single-screen wallet-linking demo:
- “Sign in with Synq” → branded Synq hosted login
- “Connect wallet” → MWA prompts to pick a wallet app
- “Link wallet” → signs a Synq-issued challenge, posts to
/users/me/wallets/link - “Send 0.001 SOL to a self-transfer” → signs a Versioned Transaction and submits it
By the end you will understand every layer of a real Synq-on-Solana- Mobile app and have working code to extend.
Step 1 — Bootstrap the Expo app
pnpm create expo my-synq-solana --template blank-typescript
cd my-synq-solanaAdd the runtime peers and SDKs:
pnpm add @synqid/react-native @synqid/solana-mobile \
@solana/web3.js \
@solana-mobile/mobile-wallet-adapter-protocol \
@solana-mobile/mobile-wallet-adapter-protocol-web3js \
react-native-mobile-wallet-adapter-protocol \
react-native-get-random-values \
expo-secure-store expo-linking expo-web-browser \
bufferThen add the MWA Expo plugin:
npx expo install expo-build-propertiesIn app.json:
{
"expo": {
"name": "my-synq-solana",
"slug": "my-synq-solana",
"scheme": "mysynqsolana",
"plugins": [
[
"expo-build-properties",
{
"android": { "compileSdkVersion": 35, "targetSdkVersion": 35 },
"ios": { "useFrameworks": "static" }
}
]
],
"ios": { "bundleIdentifier": "id.synq.demo.solana" },
"android": { "package": "id.synq.demo.solana" }
}
}Add a polyfill for Buffer and crypto.getRandomValues in
polyfills.ts at the project root:
import 'react-native-get-random-values'
import { Buffer } from 'buffer'
;(globalThis as any).Buffer = BufferImport it at the very top of App.tsx:
import './polyfills'You only need to do this once. The peer deps require it.
Step 2 — Register an App with Synq
In the Synq Dashboard :
- Create an Org. Create a Brand under it. Set logo + colors.
- Under the Brand, create an App named
my-synq-solana-dev. - Redirect URI:
mysynqsolana://oauth/callback. This must match theschemein yourapp.jsonand the path used by the SDK. - Copy the App’s
client_idandclient_secret. Both go into.env:
# .env
EXPO_PUBLIC_SYNQ_CLIENT_ID=app_xxx
EXPO_PUBLIC_SYNQ_ISSUER=https://api.synq.id
EXPO_PUBLIC_SYNQ_API_URL=https://api.synq.id
EXPO_PUBLIC_SOLANA_RPC_ENDPOINT=https://api.mainnet-beta.solana.com(client_secret does not ship in mobile apps — the device flow
and PKCE handle the security boundary. Only Web/server clients
need the secret. See
Concepts → Sessions and tokens.)
Step 3 — Wire <SynqProvider>
Create src/synq.ts:
import { createSynqConfig } from '@synqid/react-native'
export const synqConfig = createSynqConfig({
clientId: process.env.EXPO_PUBLIC_SYNQ_CLIENT_ID!,
issuer: process.env.EXPO_PUBLIC_SYNQ_ISSUER!,
redirectUri: 'mysynqsolana://oauth/callback',
scopes: ['openid', 'profile', 'email'],
})In App.tsx:
import './polyfills'
import { SynqProvider } from '@synqid/react-native'
import { synqConfig } from './src/synq'
import { Home } from './src/Home'
export default function App() {
return (
<SynqProvider config={synqConfig}>
<Home />
</SynqProvider>
)
}Step 4 — Wire <SolanaMobileProvider>
Wrap your app’s authenticated tree:
// src/Home.tsx
import { useSynqHttp, useUser } from '@synqid/react-native'
import { SolanaMobileProvider } from '@synqid/solana-mobile/react-native'
import { Button, SafeAreaView, Text } from 'react-native'
import { WalletScreen } from './WalletScreen'
export function Home() {
const { user, signIn } = useUser()
if (!user) {
return (
<SafeAreaView>
<Button title="Sign in with Synq" onPress={() => signIn()} />
</SafeAreaView>
)
}
return (
<AuthenticatedTree />
)
}
function AuthenticatedTree() {
const http = useSynqHttp()
return (
<SolanaMobileProvider
apiUrl={process.env.EXPO_PUBLIC_SYNQ_API_URL!}
http={http}
rpc={process.env.EXPO_PUBLIC_SOLANA_RPC_ENDPOINT!}
cluster="mainnet-beta"
identity={{
name: 'My Synq Solana',
uri: 'https://example.com',
icon: 'favicon.ico',
}}
>
<WalletScreen />
</SolanaMobileProvider>
)
}The <SolanaMobileProvider> is mounted only after the user is
signed in. Mounting it earlier wires http against a missing
session — the SDK throws.
Step 5 — Build the wallet screen
src/WalletScreen.tsx:
import {
useWallet,
useLinkedWallets,
useTransactionSigner,
useConnection,
} from '@synqid/solana-mobile/react-native'
import { SystemProgram, Transaction } from '@solana/web3.js'
import { useState } from 'react'
import { Button, SafeAreaView, Text, View } from 'react-native'
export function WalletScreen() {
const { wallet, authorize, deauthorize } = useWallet()
const { linkedWallets, signAndLink, refresh } = useLinkedWallets()
const { signTransaction } = useTransactionSigner()
const connection = useConnection()
const [status, setStatus] = useState<string>('')
const isCurrentLinked =
!!wallet && linkedWallets.some(w => w.publicKey === wallet.address)
const link = async () => {
setStatus('Requesting wallet signature…')
try {
const linked = await signAndLink({ label: 'My demo wallet' })
setStatus(`Linked ${linked.publicKey.slice(0, 8)}…`)
} catch (err) {
setStatus(err instanceof Error ? err.message : 'Failed')
}
}
const sendSelfTransfer = async () => {
if (!wallet) return
setStatus('Building transaction…')
const { blockhash } = await connection.getLatestBlockhash()
const tx = new Transaction({
recentBlockhash: blockhash,
feePayer: wallet.publicKey,
}).add(
SystemProgram.transfer({
fromPubkey: wallet.publicKey,
toPubkey: wallet.publicKey,
lamports: 1_000_000,
})
)
const signed = await signTransaction(tx)
const sig = await connection.sendRawTransaction(signed.serialize())
setStatus(`Sent ${sig.slice(0, 12)}…`)
}
return (
<SafeAreaView style={{ padding: 24, gap: 16 }}>
<Text>Wallet: {wallet ? wallet.address.slice(0, 12) + '…' : '(none)'}</Text>
{!wallet && <Button title="Connect wallet" onPress={authorize} />}
{wallet && <Button title="Disconnect" onPress={deauthorize} />}
{wallet && !isCurrentLinked && (
<Button title="Link to Synq" onPress={link} />
)}
{wallet && isCurrentLinked && (
<Button title="Send 0.001 SOL (self)" onPress={sendSelfTransfer} />
)}
<Text>Status: {status}</Text>
<View style={{ marginTop: 16 }}>
<Text style={{ fontWeight: 'bold' }}>Linked wallets ({linkedWallets.length}):</Text>
{linkedWallets.map(w => (
<Text key={w.id}>
{w.publicKey.slice(0, 12)}… {w.label && `— ${w.label}`}
</Text>
))}
</View>
<Button title="Refresh linked wallets" onPress={refresh} />
</SafeAreaView>
)
}Step 6 — Build and run
For Android:
eas build --profile development --platform androidWhen the build finishes, install the APK on a device with Phantom or Solflare installed. Open the app, sign in, connect a wallet, link it, send the self-transfer.
For iOS:
eas build --profile development --platform iosInstall via TestFlight or eas device:create for a registered
device. iOS requires a real device — the simulator doesn’t have
wallet apps installed.
Step 7 — Wire the webhook (server side)
A Next.js Route Handler that listens for user.wallet.linked and
reconciles to your product DB. Bring this in under any backend you
already have — the code below uses the
@synqid/nextjs shape.
// app/api/synq/webhook/route.ts
import { verifyWebhookSignature } from '@synqid/js'
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('synq-signature')
if (!sig) return new Response(null, { status: 400 })
const ok = verifyWebhookSignature({
signature: sig,
payload: body,
secret: process.env.SYNQ_WEBHOOK_SECRET!,
})
if (!ok) return new Response(null, { status: 401 })
const event = JSON.parse(body) as { type: string; data: any; id: string }
// Idempotency: dedupe on event.id.
if (await alreadyProcessed(event.id)) {
return new Response(null, { status: 200 })
}
if (event.type === 'user.wallet.linked') {
await db.userWallets.upsert({
userId: event.data.userId,
walletId: event.data.walletId,
publicKey: event.data.publicKey,
label: event.data.label,
isPrimary: event.data.isPrimary,
})
} else if (event.type === 'user.wallet.unlinked') {
await db.userWallets.delete({ walletId: event.data.walletId })
} else if (event.type === 'user.wallet.primary_changed') {
await db.userWallets.updateMany({
where: { userId: event.data.userId },
data: { isPrimary: false },
})
await db.userWallets.update({
where: { walletId: event.data.newPrimaryWalletId },
data: { isPrimary: true },
})
}
await markProcessed(event.id)
return new Response(null, { status: 200 })
}In the Synq dashboard, register this endpoint as a webhook for the events listed above. Save the webhook secret to your env vars; the signature verification uses it.
What you have at this point
A complete Synq + Solana Mobile app:
- Branded Synq sign-in via PKCE-authorization-code flow
- MWA wallet authorization on Android and iOS
- Sign-to-link against Synq’s
/users/me/wallets - Transaction signing for product flows
- Server-side webhook ingest that keeps your product DB synced with Synq’s truth
The same code patterns extend to N wallets per user, transaction
batching via signAllTransactions, deep transaction history reads
via connection.getSignaturesForAddress, and any other flow Solana
apps need.
Going further
- Production checklist: walk
Production checklist before
flipping the App from
devtoprodin the dashboard. - Multi-environment: separate Apps per environment — see Multi-environment setup.
- Rotation: schedule quarterly rotations of
client_secretand the webhook signing secret — see Rotate everything. - Native Android / iOS: if you want to ship a non-Expo native
app, the Kotlin and Swift SDKs are tracked in
@synqid/solana-mobile→ Native platforms.
If you walked this end to end and something didn’t work as documented, email support@synq.id with the step number — we use that feedback to keep this page tight.