> ## Documentation Index
> Fetch the complete documentation index at: https://developer.jtl-software.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Service Account Authentication

> Obtain a access token with OAuth 2.0 client credentials, cache it, and call the JTL Cloud API from your backend.

A service account allows your backend to authenticate as your app without a merchant being signed in. It uses the OAuth 2.0 client credentials grant to obtain a access token.

Use a service account when your backend needs to make API calls on its own, such as:

* Processing webhooks
* Running scheduled jobs
* Calling the API on behalf of a merchant after verifying their app token

The permissions available to the service account are controlled by the scopes declared in your app manifest.

## Before You Start

Your backend needs the credentials issued when you registered your app.

| Variable        | Value                                                                                                            |
| --------------- | ---------------------------------------------------------------------------------------------------------------- |
| `CLIENT_ID`     | Client ID from the [Partner Portal](https://partner.jtl-cloud.com/) under your app's **Service Account** section |
| `CLIENT_SECRET` | Client secret, shown once at registration                                                                        |

The [CLI's](/cloud/get-started/quick-start/from-template#1-create-the-project-using-cloud-apps-cli) `npm run register` writes both to your backend `.env`. Your app manifest declares the service account under `authentication.serviceAccount`. See [App Manifest: Authentication](/cloud/guides/cloud-apps/app-manifest#authentication).

<Warning>
  The client secret is shown once and cannot be retrieved afterwards. Keep it on your server, never in frontend code or a public repository.
</Warning>

## Getting a Access Token

Your backend sends its credentials to the token endpoint using HTTP Basic authentication.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // lib/jtl-auth.ts
  const AUTH_ENDPOINT = 'https://id.jtl-cloud.com/oauth2/token';
  const API_BASE_URL = 'https://api.jtl-cloud.com';
  const TOKEN_SCOPE = 'openid';


  export async function getAccessToken(): Promise<string> {
      const clientId = process.env.CLIENT_ID;
      const clientSecret = process.env.CLIENT_SECRET;

      if (!clientId || !clientSecret) {
          throw new Error('CLIENT_ID and CLIENT_SECRET must be defined in environment variables');
      }

      const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');

      const body = new URLSearchParams({ grant_type: 'client_credentials' });
      body.set('scope', TOKEN_SCOPE);

      const response = await fetch(AUTH_ENDPOINT, {
          method: 'POST',
          headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
              Authorization: `Basic ${credentials}`,
          },
          body,
      });

      if (!response.ok) {
          const error = await response.json().catch(() => null);
          throw new Error(`Token request failed (${response.status}): ${error?.error || 'unknown'}`);
      }

      const data = await response.json();
      return data.access_token;
  }

  export { API_BASE_URL };
  ```

  ```csharp C# theme={null}
  // JtlAuth.cs
  using System.Net.Http.Headers;
  using System.Text;
  using System.Text.Json;

  public static class JtlAuth
  {
      private const string AuthEndpoint = "https://id.jtl-cloud.com/oauth2/token";
      public const string ApiBaseUrl = "https://api.jtl-cloud.com";
      private const string TokenScope = "openid";

      private static readonly HttpClient HttpClient = new();

      public static async Task<string> GetAccessTokenAsync()
      {
          var clientId = Environment.GetEnvironmentVariable("CLIENT_ID");
          var clientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET");

          if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
          {
              throw new InvalidOperationException(
                  "CLIENT_ID and CLIENT_SECRET must be defined in environment variables"
              );
          }

          var credentials = Convert.ToBase64String(
              Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")
          );

          using var request = new HttpRequestMessage(HttpMethod.Post, AuthEndpoint);

          request.Headers.Authorization =
              new AuthenticationHeaderValue("Basic", credentials);

          request.Content = new FormUrlEncodedContent(new[]
          {
              new KeyValuePair<string, string>("grant_type", "client_credentials"),
              new KeyValuePair<string, string>("scope", TokenScope)
          });

          var response = await HttpClient.SendAsync(request);

          if (!response.IsSuccessStatusCode)
          {
              string? errorCode = null;

              try
              {
                  var errorBody = await response.Content.ReadAsStringAsync();
                  using var errorDoc = JsonDocument.Parse(errorBody);

                  if (errorDoc.RootElement.TryGetProperty("error", out var error))
                  {
                      errorCode = error.GetString();
                  }
              }
              catch
              {
                  // Ignore parse failures and use "unknown".
              }

              throw new HttpRequestException(
                  $"Token request failed ({(int)response.StatusCode}): {errorCode ?? "unknown"}"
              );
          }

          var json = await response.Content.ReadAsStringAsync();
          using var doc = JsonDocument.Parse(json);

          return doc.RootElement.GetProperty("access_token").GetString()
              ?? throw new InvalidOperationException(
                  "Response did not contain an access_token"
              );
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/JtlAuth.php
  declare(strict_types=1);

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use GuzzleHttp\Exception\RequestException;
  use RuntimeException;

  final class JtlAuth
  {
      private const AUTH_ENDPOINT = 'https://id.jtl-cloud.com/oauth2/token';
      public const API_BASE_URL = 'https://api.jtl-cloud.com';
      private const TOKEN_SCOPE = 'openid';

      private static ?Client $httpClient = null;

      public static function getAccessToken(): string
      {
          $clientId = getenv('CLIENT_ID') ?: null;
          $clientSecret = getenv('CLIENT_SECRET') ?: null;

          if (!$clientId || !$clientSecret) {
              throw new RuntimeException(
                  'CLIENT_ID and CLIENT_SECRET must be defined in environment variables'
              );
          }

          $credentials = base64_encode("{$clientId}:{$clientSecret}");

          try {
              $response = self::httpClient()->post(self::AUTH_ENDPOINT, [
                  'headers' => [
                      'Content-Type' => 'application/x-www-form-urlencoded',
                      'Authorization' => "Basic {$credentials}",
                  ],
                  'form_params' => [
                      'grant_type' => 'client_credentials',
                      'scope' => self::TOKEN_SCOPE,
                  ],
              ]);
          } catch (RequestException $e) {
              $status = $e->getResponse()?->getStatusCode() ?? 0;
              $errorCode = 'unknown';

              if ($e->hasResponse()) {
                  $body = (string) $e->getResponse()->getBody();
                  $decoded = json_decode($body, true);
                  $errorCode = $decoded['error'] ?? 'unknown';
              }

              throw new RuntimeException(
                  "Token request failed ({$status}): {$errorCode}",
                  previous: $e
              );
          }

          $data = json_decode(
              (string) $response->getBody(),
              true,
              flags: JSON_THROW_ON_ERROR
          );

          return $data['access_token']
              ?? throw new RuntimeException(
                  'Response did not contain an access_token'
              );
      }

      private static function httpClient(): Client
      {
          return self::$httpClient ??= new Client();
      }
  }
  ```

  ```bash cURL theme={null}
  curl -X POST 'https://id.jtl-cloud.com/oauth2/token' \
    -u 'CLIENT_ID:CLIENT_SECRET' \
    -H 'Content-Type: application/x-www-form-urlencoded' \
    -d 'grant_type=client_credentials' \
    -d 'scope=openid'
  ```
</CodeGroup>

<Note>
  The token endpoint requires HTTP Basic authentication. Sending the credentials in the request body instead of the `Authorization` header is rejected.
</Note>

### Token Response

A successful request returns:

```json theme={null}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 86399,
  "scope": "",
  "token_type": "bearer"
}
```

| Field          | Description                                                  |
| -------------- | ------------------------------------------------------------ |
| `access_token` | JWT used in the `Authorization: Bearer` header for API calls |
| `expires_in`   | Token lifetime in seconds (approximately 24 hours)           |
| `token_type`   | Always `bearer`                                              |

## Caching the Token

Access tokens are valid for approximately 24 hours. Requesting a new one on every API call adds latency and unnecessary load on the token endpoint. Hold the token in memory and request a new one shortly before it expires.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // lib/token-cache.ts
  import { getAccessToken } from './jtl-auth';

  let cachedToken: string | null = null;
  let tokenExpiresAt = 0;
  let inflightRequest: Promise<string> | null = null;

  export async function getCachedAccessToken(): Promise<string> {
      const now = Date.now();
      const bufferMs = 60_000;

      if (cachedToken && now < tokenExpiresAt - bufferMs) {
          return cachedToken;
      }

      if (inflightRequest) {
          return inflightRequest;
      }

      inflightRequest = (async () => {
          try {
              const token = await getAccessToken();

              // Decode the JWT to read the expiry (without verifying, since we just received it)
              const payload = JSON.parse(
                  Buffer.from(token.split('.')[1], 'base64url').toString()
              );

              cachedToken = token;
              tokenExpiresAt = payload.exp * 1000;

              return cachedToken!;
          } finally {
              inflightRequest = null;
          }
      })();

      return inflightRequest;
  }

  export function clearTokenCache(): void {
      cachedToken = null;
      tokenExpiresAt = 0;
  }
  ```

  ```csharp C# theme={null}
  // TokenCache.cs
  using System.Text.Json;

  public static class TokenCache
  {
      private static string? _cachedToken = null;
      private static long _tokenExpiresAt = 0;
      private static readonly SemaphoreSlim _lock = new(1, 1);

      public static async Task<string> GetCachedAccessTokenAsync()
      {
          var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
          const long bufferMs = 60_000;

          if (_cachedToken != null && now < _tokenExpiresAt - bufferMs)
              return _cachedToken;

          await _lock.WaitAsync();
          try
          {
              now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
              if (_cachedToken != null && now < _tokenExpiresAt - bufferMs)
                  return _cachedToken;

              var token = await JtlAuth.GetAccessTokenAsync();

              // Decode the JWT to read the expiry (without verifying, since we just received it)
              var payloadBase64 = token.Split('.')[1];
              var paddedPayload = payloadBase64.PadRight(
                  payloadBase64.Length + (4 - payloadBase64.Length % 4) % 4, '='
              );
              var payloadJson = System.Text.Encoding.UTF8.GetString(
                  Convert.FromBase64String(paddedPayload)
              );
              using var doc = JsonDocument.Parse(payloadJson);
              var exp = doc.RootElement.GetProperty("exp").GetInt64();

              _cachedToken = token;
              _tokenExpiresAt = exp * 1000;

              return _cachedToken;
          }
          finally
          {
              _lock.Release();
          }
      }

      public static void ClearCache()
      {
          _cachedToken = null;
          _tokenExpiresAt = 0;
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/TokenCache.php
  declare(strict_types=1);

  namespace App\Jtl;

  use RuntimeException;

  final class TokenCache
  {
      private static ?string $cachedToken = null;
      private static int $tokenExpiresAt = 0;

      public static function getCachedAccessToken(): string
      {
          $now = (int) (microtime(true) * 1000);
          $bufferMs = 60_000;

          if (self::$cachedToken !== null && $now < self::$tokenExpiresAt - $bufferMs) {
              return self::$cachedToken;
          }

          $token = JtlAuth::getAccessToken();

          // Decode the JWT to read the expiry (without verifying, since we just received it)
          $parts = explode('.', $token);
          if (count($parts) !== 3) {
              throw new RuntimeException('Invalid JWT format. Expected 3 dot-separated parts');
          }

          $payload = json_decode(
              self::base64UrlDecode($parts[1]),
              true,
              flags: JSON_THROW_ON_ERROR
          );

          self::$cachedToken = $token;
          self::$tokenExpiresAt = $payload['exp'] * 1000;

          return self::$cachedToken;
      }

      public static function clearCache(): void
      {
          self::$cachedToken = null;
          self::$tokenExpiresAt = 0;
      }

      private static function base64UrlDecode(string $input): string
      {
          $padded = strtr($input, '-_', '+/');
          $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
          return base64_decode($padded);
      }
  }
  ```
</CodeGroup>

The 60-second buffer prevents a token expiring between the cache check and the API call.

<Note>
  This in-memory cache works for a single-instance server. If you run multiple instances behind a load balancer, use a shared cache such as Redis so they do not each hold their own token.
</Note>

## Calling the API

Access tokens are not tied to a merchant, so every call names the tenant it applies to in the `X-Tenant-ID` header. Take that value from a verified app token rather than from the incoming request. See [App Token Authentication](/cloud/guides/cloud-apps/app-token-authentication#calling-the-api-on-behalf-of-a-merchant).

If a call returns `401 Unauthorized`, clear the cached token and retry once with a fresh one.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // lib/api-client.ts
  import { getCachedAccessToken, clearTokenCache } from './token-cache';

  export async function callApiWithRetry(url: string, tenantId: string) {
      let token = await getCachedAccessToken();

      let response = await fetch(url, {
          headers: {
              Authorization: `Bearer ${token}`,
              'X-Tenant-ID': tenantId,
          },
      });

      if (response.status === 401) {
          clearTokenCache();
          token = await getCachedAccessToken();

          response = await fetch(url, {
              headers: {
                  Authorization: `Bearer ${token}`,
                  'X-Tenant-ID': tenantId,
                  'Content-Type': 'application/json',
              },
          });
      }

      return response;
  }
  ```

  ```csharp C# theme={null}
  // ApiClient.cs
  using System.Net.Http.Headers;

  public static class ApiClient
  {
      private static readonly HttpClient HttpClient = new();

      public static async Task<HttpResponseMessage> CallApiWithRetryAsync(string url, string tenantId)
      {
          var token = await TokenCache.GetCachedAccessTokenAsync();
          var response = await SendRequestAsync(url, tenantId, token);

          if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
          {
              TokenCache.ClearCache();
              token = await TokenCache.GetCachedAccessTokenAsync();
              response = await SendRequestAsync(url, tenantId, token);
          }

          return response;
      }

      private static async Task<HttpResponseMessage> SendRequestAsync(string url, string tenantId, string token)
      {
          var request = new HttpRequestMessage(HttpMethod.Get, url);
          request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
          request.Headers.Add("X-Tenant-ID", tenantId);
          request.Headers.Add("Content-Type", "application/json");
          return await HttpClient.SendAsync(request);
      }
  }
  ```

  ```php PHP theme={null}
  <?php
  // src/Jtl/ApiClient.php
  declare(strict_types=1);

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use GuzzleHttp\Exception\RequestException;
  use GuzzleHttp\Psr7\Response;

  final class ApiClient
  {
      private static ?Client $httpClient = null;

      public static function callApiWithRetry(string $url, string $tenantId): Response
      {
          $token = TokenCache::getCachedAccessToken();
          $response = self::sendRequest($url, $tenantId, $token);

          if ($response->getStatusCode() === 401) {
              TokenCache::clearCache();
              $token = TokenCache::getCachedAccessToken();
              $response = self::sendRequest($url, $tenantId, $token);
          }

          return $response;
      }

      private static function sendRequest(string $url, string $tenantId, string $token): Response
      {
          try {
              return self::httpClient()->get($url, [
                  'headers' => [
                      'Authorization' => "Bearer {$token}",
                      'X-Tenant-ID' => $tenantId,
                      'Content-Type' => 'application/json',
                  ],
                  'http_errors' => false,
              ]);
          } catch (RequestException $e) {
              return $e->getResponse() ?? throw $e;
          }
      }

      private static function httpClient(): Client
      {
          return self::$httpClient ??= new Client();
      }
  }
  ```
</CodeGroup>

## Scope Enforcement

Calls made with a access token are bound by the scopes declared in `capabilities.erp.api.scopes` in your manifest. A call that exceeds them returns `403 Forbidden`.

This holds even when a merchant is signed in. A backend that verifies an app token and then calls the API with its service account is making a machine call, so the manifest's scopes apply rather than the merchant's own permissions. See [Scopes & Permissions](/cloud/guides/essentials/authentication/scopes-permissions#how-scopes-are-enforced).

## Common Errors

The failures you are most likely to hit, and what causes them.

<AccordionGroup>
  <Accordion title="401 Unauthorized: invalid_client">
    The `CLIENT_ID` or `CLIENT_SECRET` is incorrect. Check both values in your `.env` for extra whitespace, missing characters, or swapped values. If the secret has been lost, register a new app version in the [Partner Portal](https://partner.jtl-cloud.com/) to receive fresh credentials.
  </Accordion>

  <Accordion title="401 Unauthorized on a call that worked earlier">
    The cached token has expired. Clear the cache and request a new one, as the retry example above does. If this happens often, confirm the cache is reading `exp` from the token rather than assuming a fixed lifetime.
  </Accordion>

  <Accordion title="400 Bad Request on the token request">
    The credentials were sent in the request body. The token endpoint requires HTTP Basic authentication, so they belong in the `Authorization` header.
  </Accordion>

  <Accordion title="403 Forbidden on an API call">
    The token is valid, but the call exceeds the scopes declared in your manifest. Add the scope your app needs and submit an updated manifest through the Partner Portal.
  </Accordion>

  <Accordion title="API calls fail with a tenant error">
    The `X-Tenant-ID` header is missing or names a tenant your app is not installed on. Take the value from a verified app token's `urn:jtl:tenant_id` claim.
  </Accordion>
</AccordionGroup>

## What's Next?

<CardGroup cols={2}>
  <Card title="App Token Authentication" icon="key" href="/cloud/guides/cloud-apps/app-token-authentication">
    Verify the app token that tells your backend which merchant a request belongs to.
  </Card>

  <Card title="Using Platform APIs" icon="database" href="/cloud/guides/cloud-apps/using-platform-apis">
    Call the JTL Cloud and JTL-Wawi APIs with the right headers and scoping.
  </Card>

  <Card title="Scopes & Permissions" icon="shield" href="/cloud/guides/essentials/authentication/scopes-permissions">
    Declare what your app can access, and understand how enforcement differs by token.
  </Card>

  <Card title="Best Practices" icon="star" href="/cloud/guides/cloud-apps/best-practices">
    Production patterns for token caching, error handling, and security.
  </Card>
</CardGroup>
