Service Clients
OAuth2 client credentials flow for server-to-server authentication
Service clients use the OAuth2 client credentials flow for machine-to-machine authentication. This is the standard authentication method for API keys, the WordPress plugin, and custom integrations.
Overview
The client credentials flow exchanges your client_id and client_secret for a JWT access token:
┌─────────────┐ ┌─────────────┐
│ Client │ │ Synoveo │
│ (WordPress) │ │ API │
└──────┬──────┘ └──────┬──────┘
│ │
│ POST /auth/token │
│ { client_id, client_secret } │
│ ──────────────────────────────────────────>│
│ │
│ { access_token, refresh_token } │
│ <──────────────────────────────────────────│
│ │
│ GET /api/v1/locations │
│ Authorization: Bearer {access_token} │
│ ──────────────────────────────────────────>│
│ │
│ { locations: [...] } │
│ <──────────────────────────────────────────│
│ │Token Endpoint
Endpoint: POST /api/v1/auth/token
Client Credentials Grant
Exchange API credentials for tokens:
POST /api/v1/auth/token HTTP/1.1
Host: api.synoveo.com
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "syncid_570_1703030400000_my_app",
"client_secret": "your_base64_encoded_secret"
}Request Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be "client_credentials" |
client_id | string | Yes | Your API key client ID |
client_secret | string | Yes | Your API key secret (base64) |
permissions | array | No | Requested permissions (subset of allowed) |
Response:
{
"status": "ok",
"data": {
"token_type": "Bearer",
"scope": "service",
"plan": "pro",
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 7776000,
"permissions": ["business.read", "business.write"],
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}Response Fields:
| Field | Type | Description |
|---|---|---|
token_type | string | Always "Bearer" |
scope | string | Always "service" for API keys |
plan | string | User's subscription plan |
access_token | string | JWT token for API requests |
expires_in | number | Token TTL in seconds (default: 7,776,000 = 90 days) |
permissions | array | Granted permissions |
refresh_token | string | Token for obtaining new access tokens |
Refresh Token Grant
When access tokens expire, use the refresh token:
POST /api/v1/auth/token HTTP/1.1
Host: api.synoveo.com
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}Request Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be "refresh_token" |
refresh_token | string | Yes | Valid refresh token |
Response: Same format as client credentials grant.
Permission Scoping
Request specific permissions to limit token access:
POST /api/v1/auth/token HTTP/1.1
Host: api.synoveo.com
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "syncid_570_...",
"client_secret": "...",
"permissions": ["business.read"]
}Permission Resolution
The granted permissions are the intersection of:
- Permissions allowed by the API key
- Permissions requested in the token request
| API Key Allows | Requested | Granted |
|---|---|---|
["business.read", "business.write"] | ["business.read"] | ["business.read"] |
["business.read", "business.write"] | (none) | ["business.read", "business.write"] |
["business.read"] | ["business.write"] | [] (error) |
Available Permissions
| Permission | Description |
|---|---|
business.read | Read locations, profiles, reviews, posts |
business.write | Update profiles, create posts, sync data |
Token Characteristics
Service Tokens vs User Tokens
| Aspect | Service Token | User Token |
|---|---|---|
| Scope | "service" | "user" |
| Issued via | Client credentials | Google OAuth |
| Access | Single assigned location | All user locations |
| Permissions | Explicit in token | Full (implicit) |
| Default TTL | 90 days | 7 days |
Token Validation
Service tokens are validated by:
- RS256 Signature - Verified with API's public key
- Issuer - Must be
https://api.synoveo.com - Audience - Must be
https://api.synoveo.com - Expiration -
expclaim must be in future - API Key Status - Underlying key must be active
Error Handling
Invalid Credentials
{
"status": "error",
"error": {
"code": "AUTH_INVALID_TOKEN",
"message": "Invalid client credentials"
}
}Deactivated API Key
{
"status": "error",
"error": {
"code": "AUTH_INSUFFICIENT_PERMISSIONS",
"message": "API key has been deactivated",
"details": {
"deactivation_reason": "billing_issue",
"deactivated_at": "2025-01-15T10:00:00Z",
"upgrade_url": "https://app.synoveo.com/dashboard/billing"
}
}
}Invalid Refresh Token
{
"status": "error",
"error": {
"code": "AUTH_INVALID_TOKEN",
"message": "Invalid or expired refresh token"
}
}Implementation Examples
Node.js
class SynoveoAuth {
constructor(clientId, clientSecret) {
this.clientId = clientId
this.clientSecret = clientSecret
this.token = null
this.refreshToken = null
this.expiresAt = null
}
async getToken() {
// Return cached token if valid (5 min buffer)
if (this.token && this.expiresAt > Date.now() + 300000) {
return this.token
}
// Try refresh if we have a refresh token
if (this.refreshToken) {
try {
return await this.refresh()
} catch (e) {
// Fall through to new token
}
}
return await this.authenticate()
}
async authenticate() {
const response = await fetch('https://api.synoveo.com/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret
})
})
if (!response.ok) {
throw new Error('Authentication failed')
}
const { data } = await response.json()
this.token = data.access_token
this.refreshToken = data.refresh_token
this.expiresAt = Date.now() + (data.expires_in * 1000)
return this.token
}
async refresh() {
const response = await fetch('https://api.synoveo.com/api/v1/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: this.refreshToken
})
})
if (!response.ok) {
this.refreshToken = null
throw new Error('Refresh failed')
}
const { data } = await response.json()
this.token = data.access_token
this.refreshToken = data.refresh_token
this.expiresAt = Date.now() + (data.expires_in * 1000)
return this.token
}
}
// Usage
const auth = new SynoveoAuth(
process.env.SYNOVEO_CLIENT_ID,
process.env.SYNOVEO_CLIENT_SECRET
)
const token = await auth.getToken()
const response = await fetch('https://api.synoveo.com/api/v1/google-business/locations', {
headers: { 'Authorization': `Bearer ${token}` }
})PHP (WordPress)
class SynoveoAuth {
private $clientId;
private $clientSecret;
private $tokenOptionKey = 'synoveo_access_token';
public function __construct($clientId, $clientSecret) {
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
}
public function getToken() {
$cached = get_option($this->tokenOptionKey);
if ($cached && $cached['expires_at'] > time() + 300) {
return $cached['token'];
}
return $this->authenticate();
}
private function authenticate() {
$response = wp_remote_post('https://api.synoveo.com/api/v1/auth/token', [
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode([
'grant_type' => 'client_credentials',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
])
]);
if (is_wp_error($response)) {
throw new Exception('Authentication failed');
}
$body = json_decode(wp_remote_retrieve_body($response), true);
update_option($this->tokenOptionKey, [
'token' => $body['data']['access_token'],
'refresh_token' => $body['data']['refresh_token'],
'expires_at' => time() + $body['data']['expires_in']
]);
return $body['data']['access_token'];
}
}Python
import requests
import time
class SynoveoAuth:
def __init__(self, client_id, client_secret):
self.client_id = client_id
self.client_secret = client_secret
self.token = None
self.refresh_token = None
self.expires_at = 0
def get_token(self):
if self.token and self.expires_at > time.time() + 300:
return self.token
if self.refresh_token:
try:
return self._refresh()
except:
pass
return self._authenticate()
def _authenticate(self):
response = requests.post(
'https://api.synoveo.com/api/v1/auth/token',
json={
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret
}
)
response.raise_for_status()
data = response.json()['data']
self.token = data['access_token']
self.refresh_token = data['refresh_token']
self.expires_at = time.time() + data['expires_in']
return self.token
def _refresh(self):
response = requests.post(
'https://api.synoveo.com/api/v1/auth/token',
json={
'grant_type': 'refresh_token',
'refresh_token': self.refresh_token
}
)
response.raise_for_status()
data = response.json()['data']
self.token = data['access_token']
self.refresh_token = data['refresh_token']
self.expires_at = time.time() + data['expires_in']
return self.tokenBest Practices
- Cache Tokens - Reuse tokens until expiration
- Proactive Refresh - Refresh 5 minutes before expiration
- Handle Errors - Implement retry with exponential backoff
- Minimum Permissions - Request only needed permissions
- Secure Storage - Never log or expose tokens
SDK Usage
The @synoveo/sdk handles the client credentials flow automatically:
import { SynoveoClient } from '@synoveo/sdk'
const client = new SynoveoClient({
clientId: process.env.SYNOVEO_CLIENT_ID,
clientSecret: process.env.SYNOVEO_CLIENT_SECRET,
// Optional: Token lifecycle hooks
hooks: {
onTokenRefresh: (token) => {
console.log('Token refreshed:', token.expiresAt)
}
}
})
// Tokens are managed automatically
const { locations } = await client.locations.list()See SDK Documentation for complete details.