Skip to Content
SDKs@synqid/js

@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.txt for 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/js

Initialize

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:

RuntimeDefault storage
BrowserlocalStorage under the key synq:session
Node / Bun / Denoin-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:login in tab B → call getSession() to refresh.
  • Sign-out in tab A causes synq:logout in 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 SynqClient per App. Reuse it.
  • signIn redirects. handleCallback on return. getSession everywhere.
  • Server: verifyAccessToken (cached JWKS), getServiceToken (cached m2m), users.me.* and friends for resource calls.
  • Errors are typed; code is the discriminator.
  • Bring your own fetch and storage for tests, edge runtimes, and custom environments.
Last updated on