@synqid/js
The core, framework-agnostic Synq client. Works in browser, Node,
Deno, Bun, and edge runtimes. ~8 kB gzipped. Zero React deps. Used
directly for backends and CLIs, and as the engine under
@synqid/react and @synqid/react-native.
AI agent? Fetch
/llms.txtfor a one-file index. If you are building a React or Next.js app, prefer @synqid/react or @synqid/nextjs — this package is the lower-level primitive.
Install
pnpm add @synqid/jsInitialize
One client per App (client_id). Construct once and reuse.
import { SynqClient } from '@synqid/js'
const synq = new SynqClient({
issuer: 'https://synq.id',
clientId: process.env.SYNQ_CLIENT_ID!,
clientSecret: process.env.SYNQ_CLIENT_SECRET, // omit for public clients
redirectUri: 'https://your-app.example.com/api/oauth/callback',
scopes: ['openid', 'profile', 'email', 'offline_access'],
// Optional:
debug: process.env.NODE_ENV !== 'production',
fetch: customFetch, // bring your own fetch
storage: customStorage, // bring your own session storage
})scopes is the default request scope. You can override per call.
Custom fetch
By default @synqid/js uses the global fetch. Override for:
- Insertion of request IDs.
- Proxy configuration.
- Centralized retry logic.
- Test mocks.
const synq = new SynqClient({
// …
fetch: async (input, init) => {
return fetch(input, {
...init,
headers: {
...(init?.headers ?? {}),
'X-Request-ID': crypto.randomUUID(),
},
})
},
})Custom storage
Where to persist the session (tokens + auto-refresh state) between calls. Defaults:
| Runtime | Default storage |
|---|---|
| Browser | localStorage under the key synq:session |
| Node / Bun / Deno | in-memory only (you bring persistence) |
| Edge (Cloudflare Workers, Vercel Edge) | in-memory only |
Custom shape:
interface SynqStorage {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
}Storage holds the refresh token. If you can’t use HttpOnly cookies (browser SPAs without a backend), at minimum encrypt the storage with a key derived from session state.
Browser sign-in flow
// On a "Sign in" button click:
await synq.signIn({ provider: 'google' })
// Redirects to Synq. Browser leaves; you don't await further here.
// On your /oauth/callback page:
await synq.handleCallback()
// Reads ?code=… from URL, exchanges at /oauth2/token, stores session,
// rewrites URL to remove ?code & ?state, returns User.
// Anywhere you need the user:
const session = await synq.getSession()
// Returns { user, tokens } or null. Refreshes the access token if
// it's within 60s of expiry; single-flight protected.
// On sign-out:
await synq.signOut({ revokeRefresh: true })
// Optionally revokes refresh token at /oauth2/revoke.signIn(options)
interface SignInOptions {
provider?:
| 'google' | 'apple' | 'microsoft' | 'discord'
| 'facebook' | 'x' | 'telegram' | 'matrica' | 'solana'
// omit to show Synq's branded sign-in surface
scope?: string[]
prompt?: 'none' | 'login' | 'consent' | 'select_account' | string
redirectTo?: string // where to send the user post-sign-in
state?: Record<string, unknown> // app state to round-trip via state
}If provider is omitted, Synq shows its sign-in surface with the
provider list. If included, Synq skips the chooser.
state is your application state — anything serializable. Synq
encodes it into the OAuth state parameter and returns it from
handleCallback() so you can resume a multi-page flow.
handleCallback()
Reads ?code=…&state=… from window.location. Validates state
matches what was set in signIn(). Exchanges code at the token
endpoint. Stores tokens. Rewrites the URL via history.replaceState
to remove the OAuth params. Returns:
interface HandleCallbackResult {
user: User
state: Record<string, unknown> | null // what you passed at signIn
}If the URL is missing code / state, throws SynqClientError.
getSession()
interface Session {
user: User
tokens: {
access_token: string
expires_at: number // unix seconds
refresh_token?: string // present when 'offline_access' granted
scope: string
}
}Returns null if no session. Auto-refreshes the access token when
within 60 seconds of expiry. Single-flight protected — concurrent
getSession() calls during refresh await the same promise.
signOut({ revokeRefresh? })
Clears local storage. If revokeRefresh: true (default), also
calls /oauth2/revoke on the refresh token. Triggers a synq:logout
event other tabs listen for (multi-tab synchronization).
Server-side: token verification
const payload = await synq.verifyAccessToken(token)
// { sub, aud, scope, iat, exp, azp, ... }Implementation:
- Fetches and caches JWKS via
createRemoteJWKSet. - Checks
iss,aud(against the brand slug — configurable),exp,iat. - Throws typed errors:
SynqAuthError('invalid_token', 'signature failed')SynqAuthError('invalid_token', 'expired')SynqAuthError('invalid_token', 'audience mismatch')
For an Express middleware shape:
import { synq } from './synq.js'
export function requireAuth(req, res, next) {
const auth = req.headers.authorization?.replace(/^Bearer /, '')
if (!auth) return res.status(401).end()
synq
.verifyAccessToken(auth)
.then((payload) => {
req.user = payload
next()
})
.catch((err) => res.status(401).json({ error: err.code }))
}For Hono / Bun / Workers:
import { Hono } from 'hono'
const app = new Hono()
app.use('/api/*', async (c, next) => {
const auth = c.req.header('Authorization')?.replace(/^Bearer /, '')
if (!auth) return c.json({ error: 'unauthorized' }, 401)
try {
c.set('user', await synq.verifyAccessToken(auth))
} catch (e) {
return c.json({ error: 'invalid_token' }, 401)
}
await next()
})Verifying with required scopes
await synq.verifyAccessToken(token, {
audience: ['marketplace', 'admin-tools'], // multi-audience accept
requireScope: ['wallets:read'], // throws if missing
})Server-side: m2m
const token = await synq.getServiceToken({
scope: ['users:read'],
})
// { access_token, expires_in }
// Cached and refreshed automatically when within 60s of expiry.The first call hits /oauth2/token with grant_type=client_credentials.
Subsequent calls return the cached token until it nears expiry, then
refresh.
Use for service-to-service traffic where there is no end user.
Server-side: Calling Synq APIs
The client exposes resource builders for the user, org, brand, webhook, and audit-log APIs:
// As a user
const user = await synq.users.me.get()
const wallets = await synq.users.me.publicKeys.list()
await synq.users.me.apiKeys.create({ name: 'CI', scopes: ['orgs:read'] })
// As an org admin (use a service token or user token with permissions)
const orgs = await synq.organizations.list()
const brand = await synq.organizations.get('acme').brands.get('marketplace')
await brand.apps.create({ name: 'Mobile', type: 'native' })
// Audit log
const log = await synq.organizations
.get('acme')
.auditLog.list({ since: '2026-06-01', limit: 200 })
// Webhooks
const wh = await synq.organizations.get('acme').webhooks.create({
url: 'https://example.com/webhook',
events: ['*'],
})All resource methods return typed objects matching API Reference.
Device flow (CLIs and agents)
const flow = await synq.createDeviceFlow({ scope: ['openid', 'profile'] })
console.log(`Open ${flow.verification_uri} and enter ${flow.user_code}`)
const tokens = await flow.poll()
// Blocks until the user approves. Honors `interval` and `slow_down`.
// Throws on `expired_token` or `access_denied`.
const user = await synq.fetchUser(tokens.access_token)
console.log(`Hi ${user.firstName}!`)For a CLI you ship to many users, persist the resulting tokens in
the OS keychain (keytar, electron-keychain) so the user signs in
once per machine.
Webhooks
import { verifyWebhookSignature } from '@synqid/js'
// In your webhook handler:
const valid = verifyWebhookSignature(
rawBody,
req.header('Synq-Signature'),
process.env.SYNQ_WEBHOOK_SECRET!,
)
if (!valid) return res.status(401).end()During rotation, pass both old and new:
const valid = verifyWebhookSignature(
rawBody,
req.header('Synq-Signature'),
[OLD_SECRET, NEW_SECRET],
)Multi-tab sync
In the browser, @synqid/js uses BroadcastChannel to keep tabs in
sync:
- Sign-in in tab A causes
synq:loginin tab B → callgetSession()to refresh. - Sign-out in tab A causes
synq:logoutin tab B → clear UI and redirect.
synq.on('login', (session) => { /* update UI */ })
synq.on('logout', () => { /* redirect to home */ })The @synqid/react <SynqProvider> wires these into React state for
you.
Errors
Every failure path throws a typed error.
class SynqError extends Error {
code: string
cause?: unknown
}
class SynqAuthError extends SynqError {
// The user could not be authenticated.
// code: 'invalid_token' | 'invalid_grant' | 'invalid_client' |
// 'access_denied' | 'consent_required' | 'login_required' | ...
}
class SynqNetworkError extends SynqError {
// Transport failure. Safe to retry.
// code: 'fetch_failed' | 'timeout' | 'aborted'
}
class SynqClientError extends SynqError {
// Misuse — bad config, missing param. NOT safe to retry.
// code: 'missing_redirect_uri' | 'invalid_state' | 'no_session' | ...
}
class SynqServerError extends SynqError {
// Synq-side 5xx. Retry with backoff.
// code: 'internal_error' | 'service_unavailable'
}Switch on code to handle specific cases:
try {
await synq.handleCallback()
} catch (err) {
if (err instanceof SynqAuthError && err.code === 'access_denied') {
// user clicked Deny on the consent screen
return showFriendlySignInPage()
}
if (err instanceof SynqNetworkError) {
// transient; show a retry button
return showRetry()
}
// genuine bug; log and surface
reportError(err)
return showSomethingWentWrong()
}Debug mode
Set debug: true (or SYNQ_DEBUG=1 env var) to log every request,
response, token decode, and state transition to console with a
[synq] prefix. Helpful when wiring up for the first time.
Mental model summary
- One
SynqClientper App. Reuse it. signInredirects.handleCallbackon return.getSessioneverywhere.- Server:
verifyAccessToken(cached JWKS),getServiceToken(cached m2m),users.me.*and friends for resource calls. - Errors are typed;
codeis the discriminator. - Bring your own
fetchandstoragefor tests, edge runtimes, and custom environments.