Authentication
How session tokens, scopes, and the permission system work under the hood.
Session Token Flow
When a user opens your add-on, SyncBooks creates a short-lived JWT session token:
text
1. User clicks your addon in SyncBooks
2. SyncBooks server creates a JWT with:
- orgId: the organization
- userId: the current user
- appId: your addon ID
- scopes: permissions granted during installation
- exp: 1 hour from now
3. Token is passed to your iframe via URL param: ?syncbooks_token=eyJ...
4. SyncBooksProvider extracts the token automatically
5. All API calls include: Authorization: Bearer <token>
6. Backend validates JWT on every requestToken Structure (Decoded JWT)
JWT Payload
{
"type": "addon_session",
"orgId": "507f1f77bcf86cd799439011",
"userId": "507f1f77bcf86cd799439012",
"appId": "com.mycompany.myapp",
"scopes": ["invoices.read", "customers.read", "customers.write"],
"jti": "a1b2c3d4-unique-id",
"iat": 1691920000,
"exp": 1691923600 // 1 hour later
}Tokens expire after 1 hour
After expiry, API calls return 401. The user needs to close and reopen the add-on to get a fresh token. Handle this gracefully in your UI.
Scope Enforcement
Every API endpoint checks the token's scopes before returning data:
text
GET /api/addon/v1/customers
→ addonSessionAuth: validates JWT ✓
→ requireAddonScope('customers.read'): checks scopes array ✓
→ Returns data
POST /api/addon/v1/customers
→ addonSessionAuth: validates JWT ✓
→ requireAddonScope('customers.write'): checks scopes array
→ If 'customers.write' not in scopes: 403 ForbiddenScope Format
// Specific scope
"customers.read" // Can list/get customers
"customers.write" // Can create/update customers
"customers.delete" // Can delete customers
// Wildcard scope
"customers.*" // All customer operations
"*" // All operations (full access)Handling 401 (Token Expired)
App.tsx
import { useSyncBooks } from '@syncbooks/addon-sdk'
function App() {
const { ready } = useSyncBooks()
const [expired, setExpired] = useState(false)
// Catch API errors globally
useEffect(() => {
const handler = (e: any) => {
if (e?.status === 401) setExpired(true)
}
window.addEventListener('unhandledrejection', handler)
return () => window.removeEventListener('unhandledrejection', handler)
}, [])
if (expired) {
return (
<div className="p-6 text-center">
<p className="font-medium">Session Expired</p>
<p className="text-sm text-muted-foreground mt-1">
Please close and reopen this add-on to continue.
</p>
<button
onClick={() => window.location.reload()}
className="mt-3 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg"
>
Reload
</button>
</div>
)
}
return <MainContent />
}Handling 403 (Insufficient Scopes)
import { customers, formatError, hasScope, ui } from '@syncbooks/addon-sdk'
// Check before attempting
if (!hasScope('customers.write')) {
ui.notify('You need "customers.write" permission for this action', 'warning')
return
}
// Or handle the error
try {
await customers.create({ name: 'Test' })
} catch (error: any) {
if (error.status === 403) {
// error.response = { requiredScope: 'customers.write', grantedScopes: [...] }
ui.notify(`Missing permission: ${error.response.requiredScope}`, 'error')
}
}Security Best Practices
- Never expose tokens to end users — don't display them in the UI
- Don't store tokens — they're short-lived and auto-provided
- Check scopes before showing UI — use
PermissionGateorhasScope() - Handle errors gracefully — 401 and 403 are expected, not crashes
- HTTPS only — your add-on URL must be HTTPS in production
Next Steps
- Scopes Reference — complete list of all permission scopes
- Error Codes — all error responses explained