Skip to content

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_...
FieldRequiredDescription
grant_typeyesAlways client_credentials.
client_idyesYour app's Client ID, xs_....
client_secretyesYour 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 ​

  1. Cache the token and reuse it for its full four hours. expires_in is 14400 seconds. Refresh it shortly before it expires, or when a call answers 401. A correct integration needs about six token calls a day.
  2. The token endpoint is rate limited: 10 requests a minute per client_id and 60 a minute per IP. Past that it answers 429 slow_down with a Retry-After header. Fetching a new token before every API call will hit this.
  3. Ten failed authentications in five minutes block that client_id and that IP for 15 minutes. Never retry a rejected secret in a loop.
  4. 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 ​

StatuserrorMeaning
400unsupported_grant_typegrant_type was not client_credentials.
401invalid_clientCredentials rejected.
429slow_downRate 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.

XSelly Open Platform API v1 · Webhooks v2