Appearance
Authentication
Every /v1 call carries an access token. You get one with the OAuth 2.0 client credentials grant, using your app's Client ID and Client Secret.
http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=xs_...&client_secret=xss_...| Field | Required | Description |
|---|---|---|
grant_type | yes | Always client_credentials. |
client_id | yes | Your app's Client ID, xs_.... |
client_secret | yes | Your app's Client Secret, xss_.... It is shown once when issued and cannot be read back, only replaced. |
Credentials go in the form body. HTTP Basic client authentication is not accepted.
The response:
json
{
"access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 14400
}Send the token on every /v1 call:
http
Authorization: Bearer eyJhbGciOiJFZERTQSIs...The token identifies your app, and your app decides which store you are working in.
Four rules worth getting right the first time
- Cache the token and reuse it for its full four hours.
expires_inis 14400 seconds. Refresh it shortly before it expires, or when a call answers401. A correct integration needs about six token calls a day. - The token endpoint is rate limited: 10 requests a minute per
client_idand 60 a minute per IP. Past that it answers429 slow_downwith aRetry-Afterheader. Fetching a new token before every API call will hit this. - Ten failed authentications in five minutes block that
client_idand that IP for 15 minutes. Never retry a rejected secret in a loop. - The token is opaque. Treat it as a string and honour
expires_in. Two calls never return the same value.
Caching the token
The pattern is the same in any language: keep the token with its expiry time, and fetch a new one only when it is about to run out. If a /v1 call answers 401, drop the cached token and try that call once more with a new one.
js
const BASE_URL = process.env.XSELLY_BASE_URL // your base URL, shown in the XSelly app
let cached = null // { token, expiresAt }
async function getAccessToken() {
// Refresh five minutes early, so a token never expires mid-request.
if (cached && Date.now() < cached.expiresAt - 5 * 60_000) return cached.token
const res = await fetch(`${BASE_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.XSELLY_CLIENT_ID,
client_secret: process.env.XSELLY_CLIENT_SECRET,
}),
})
const body = await res.json()
// invalid_client is final: do not retry it, or the client_id gets blocked.
if (!res.ok) throw new Error(`XSelly token: ${res.status} ${body.error}`)
cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 }
return cached.token
}
export async function xselly(path, payload = {}) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (res.status === 401 && attempt === 1) {
cached = null // expired or revoked: get a new token and try once more
continue
}
const body = await res.json()
if (!res.ok) {
const err = new Error(body.error_description ?? body.error)
err.status = res.status
err.code = body.error
err.requestId = res.headers.get('X-Request-Id')
throw err
}
return body
}
}php
<?php
// PHP-FPM starts every request fresh, so a static variable would fetch a new
// token on every page view and hit the rate limit. Keep it somewhere shared:
// APCu here, but Redis or a database row work the same way.
function xsellyAccessToken(): string
{
$cached = apcu_fetch('xselly_token');
if ($cached !== false && time() < $cached['expires_at'] - 300) {
return $cached['token'];
}
$ch = curl_init(getenv('XSELLY_BASE_URL') . '/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => getenv('XSELLY_CLIENT_ID'),
'client_secret' => getenv('XSELLY_CLIENT_SECRET'),
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// invalid_client is final: do not retry it, or the client_id gets blocked.
if ($status !== 200) {
throw new RuntimeException("XSelly token: HTTP $status " . ($body['error'] ?? ''));
}
$expiresAt = time() + $body['expires_in'];
apcu_store('xselly_token', ['token' => $body['access_token'], 'expires_at' => $expiresAt], $body['expires_in']);
return $body['access_token'];
}python
import os
import time
import requests
BASE_URL = os.environ["XSELLY_BASE_URL"] # your base URL, shown in the XSelly app
_cached = {"token": None, "expires_at": 0.0}
def access_token() -> str:
# Refresh five minutes early, so a token never expires mid-request.
if _cached["token"] and time.time() < _cached["expires_at"] - 300:
return _cached["token"]
res = requests.post(
f"{BASE_URL}/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": os.environ["XSELLY_CLIENT_ID"],
"client_secret": os.environ["XSELLY_CLIENT_SECRET"],
},
timeout=10,
)
# invalid_client is final: do not retry it, or the client_id gets blocked.
res.raise_for_status()
body = res.json()
_cached.update(token=body["access_token"], expires_at=time.time() + body["expires_in"])
return _cached["token"]If you run many processes or servers, share one cached token between them, for example in Redis. Ten workers that each fetch their own token at start-up can hit the ten-a-minute limit together.
Token errors
| Status | error | Meaning |
|---|---|---|
400 | unsupported_grant_type | grant_type was not client_credentials. |
401 | invalid_client | Credentials rejected. |
429 | slow_down | Rate limited. Wait for as long as Retry-After says. |
json
{ "error": "invalid_client", "error_description": "client authentication failed" }Every authentication failure answers the same invalid_client, whether the client_id is unknown, the secret is wrong or the app has expired. This is by design: the endpoint reveals nothing about which clients exist.
Replacing a Client Secret
If a secret is lost or may have leaked, open the app in XSelly and press สร้าง Client Secret ใหม่. The new secret is shown once. The old one keeps working for another 24 hours, so you have time to deploy the new one before anything breaks.
Keeping credentials safe
- The Client Secret is a password for the store. Keep it on your server, never in a browser, a mobile app or a public repository.
- The access token is just as sensitive for its four hours. Do not log it.
- Your Webhook Secret is a different value, used only to verify webhook signatures. It is not an API credential.
