> ## 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.

# Act on Behalf of a User

> Exchange your service account token for a delegated token that calls the JTL Cloud API as a specific merchant user, bounded by that user permissions.

Your service account normally calls platform APIs as your app. It does not represent a specific merchant user.

With **on-behalf-of authentication**, your backend can exchange a service account token for a token that represents a specific merchant user. API calls made with that token are authorized using that user's JTL-Wawi permissions.

This is useful when an agent or backend process needs to act for a person. Instead of giving it your app's broader access, you can have it operate with the same permissions as the user it is acting for.

| | Access token | On-behalf-of token |
| - | - | - |
| Acts as | Your app | A specific merchant user |
| Bounded by | The scopes declared in your manifest | The user's JTL-Wawi permissions |
| Use for | Background jobs, sync, webhook processing | Work a person initiated, or anything that must respect the user's permission level |

An on-behalf-of token cannot give your app more access than the user has. The gateway enforces the user's permissions, so your app does not need to manage those roles itself.

If your backend only ever acts as your app, keep using your access token. See [Service Account Authentication](/cloud/guides/cloud-apps/service-account-authentication).

## How It Works

Your app does not create the on-behalf-of token itself. It first authenticates with its service account, then sends that access token to the Account Service to request a token for a specific user.

```mermaid theme={null}
sequenceDiagram
    autonumber

    participant App as Your App
    participant IDP as JTL Identity Provider
    participant ACC as Account Service
    participant API as JTL Cloud API

    App->>IDP: Request access token (client credentials)
    IDP-->>App: Access token

    App->>ACC: POST /account/identity/act-as-user-token
    Note over App,ACC: Bearer access token, body names userId and tenantId
    ACC-->>App: On-behalf-of token and refresh token

    App->>API: Call the API as the user
    API-->>App: Response
```

When the Account Service receives the request, it checks:

* Your app is installed for the specified tenant.
* The capability is enabled for your app.
* The specified user belongs to that tenant.

If any of these checks fail, the request is rejected.

If they pass, the Account Service returns an on-behalf-of token that your app can use to call JTL Cloud APIs as that user.

## Before You Start

Three things need to be in place.

<Steps>
  <Step title="Enable the capability">
    Your app's service account declares it in the app manifest:

    ```json theme={null}
    "authentication": {
      "serviceAccount": {
        "clientCredentials": true,
        "onBehalfOfUser": true,
        "description": "Agent that acts for the signed-in user"
      }
    }
    ```

    You can also turn on **Act on behalf of users** on the service account in the [Partner Portal](https://partner.jtl-cloud.com/).
  </Step>

  <Step title="Have your service account credentials">
    The `CLIENT_ID` and `CLIENT_SECRET` issued when you registered your app. See [Service Account Authentication](/cloud/guides/cloud-apps/service-account-authentication).
  </Step>

  <Step title="Know the user and the tenant">
    You name the user by their JTL ID and the tenant by its ID. Both come from a verified token:

    | Value | Where it comes from |
    | - | - |
    | `userId` | The `sub` claim of a verified app token |
    | `tenantId` | The `urn:jtl:tenant_id` claim of the same token |

    An embedded app reads these from the app token its frontend sends. An app on its own domain reads them from the token it receives at sign-in. See [App Token Authentication](/cloud/guides/cloud-apps/app-token-authentication) and [Standalone Authentication](/cloud/guides/cloud-apps/standalone-authentication).

    You do not need the user's own token at exchange time, only their ID. Your app must be installed for the tenant, and the user must be a member of it.
  </Step>
</Steps>

## Getting an On-Behalf-Of Token

The exchange takes two calls: one to authenticate as your app, one to swap that credential for a user-bound token.

<Steps>
  <Step title="Get your access token">
    Request a `client_credentials` token as you would for any other API call.

    ```bash theme={null}
    curl -X POST https://id.jtl-cloud.com/oauth/v2/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -H "Authorization: Basic $(echo -n "$CLIENT_ID:$CLIENT_SECRET" | base64)" \
      -d "grant_type=client_credentials" \
      -d "scope=openid"
    ```
  </Step>

  <Step title="Exchange it for a user-bound token">
    Send the access token as the bearer, and name the user and tenant in the body.

    ```bash theme={null}
    curl -X POST https://api.jtl-cloud.com/account/identity/act-as-user-token \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d "{\"userId\": \"$USER_ID\", \"tenantId\": \"$TENANT_ID\"}"
    ```

    The response carries the token, a refresh token, and its lifetime in seconds.

    ```json theme={null}
    {
      "accessToken": "eyJ...",
      "refreshToken": "eyJ...",
      "expiresIn": 3599
    }
    ```
  </Step>

  <Step title="Call the API as the user">
    Use `accessToken` as the bearer, and send the same tenant you named in the exchange.

    ```bash theme={null}
    curl -X POST https://api.jtl-cloud.com/erp/v2/graphql \
      -H "Authorization: Bearer $OBO_ACCESS_TOKEN" \
      -H "X-Tenant-ID: $TENANT_ID" \
      -H "Content-Type: application/json" \
      -d '{"query": "query { QueryItems(first: 10) { nodes { id sku name } } }"}'
    ```
  </Step>
</Steps>

## Implementation

The exchange in each language, taking the access token from your service account and returning a token bound to the user.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // lib/act-as-user.ts
  import { getCachedAccessToken } from './token-cache';

  const API_BASE_URL = 'https://api.jtl-cloud.com';

  export interface OnBehalfOfToken {
      accessToken: string;
      refreshToken: string;
      expiresIn: number;
  }

  export async function actAsUser(
      userId: string,
      tenantId: string,
  ): Promise<OnBehalfOfToken> {
      const accessToken = await getCachedAccessToken();

      const response = await fetch(
          `${API_BASE_URL}/account/identity/act-as-user-token`,
          {
              method: 'POST',
              headers: {
                  Authorization: `Bearer ${accessToken}`,
                  'Content-Type': 'application/json',
              },
              body: JSON.stringify({ userId, tenantId }),
          },
      );

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

      return response.json();
  }
  ```

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

  namespace Backend.Services;

  public record OnBehalfOfToken(string AccessToken, string RefreshToken, int ExpiresIn);

  public class ActAsUserService
  {
      private const string ApiBaseUrl = "https://api.jtl-cloud.com";

      private readonly HttpClient _http;

      public ActAsUserService(HttpClient http)
      {
          _http = http;
      }

      public async Task<OnBehalfOfToken> ActAsUserAsync(string userId, string tenantId)
      {
          var accessToken = await TokenCache.GetCachedAccessTokenAsync();

          var request = new HttpRequestMessage(
              HttpMethod.Post,
              $"{ApiBaseUrl}/account/identity/act-as-user-token");
          request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
          request.Content = new StringContent(
              JsonSerializer.Serialize(new { userId, tenantId }),
              Encoding.UTF8,
              "application/json");

          var response = await _http.SendAsync(request);
          var body = await response.Content.ReadAsStringAsync();

          if (!response.IsSuccessStatusCode)
          {
              throw new HttpRequestException(
                  $"Exchange failed ({(int)response.StatusCode}): {body}");
          }

          using var doc = JsonDocument.Parse(body);
          var root = doc.RootElement;

          return new OnBehalfOfToken(
              root.GetProperty("accessToken").GetString()!,
              root.GetProperty("refreshToken").GetString()!,
              root.GetProperty("expiresIn").GetInt32());
      }
  }
  ```

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

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use RuntimeException;

  final class ActAsUser
  {
      private const API_BASE_URL = 'https://api.jtl-cloud.com';

      public function __construct(private readonly Client $httpClient) {}

      public function actAsUser(string $userId, string $tenantId): array
      {
          $accessToken = TokenCache::getCachedAccessToken();

          $response = $this->httpClient->post(
              self::API_BASE_URL . '/account/identity/act-as-user-token',
              [
                  'headers' => [
                      'Authorization' => "Bearer {$accessToken}",
                      'Content-Type' => 'application/json',
                  ],
                  'json' => [
                      'userId' => $userId,
                      'tenantId' => $tenantId,
                  ],
                  'http_errors' => false,
              ]
          );

          $body = (string) $response->getBody();

          if ($response->getStatusCode() >= 400) {
              throw new RuntimeException(
                  "Exchange failed ({$response->getStatusCode()}): {$body}"
              );
          }

          return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
      }
  }
  ```
</CodeGroup>

## Refreshing the Token

For long-running or unattended work, rotate the token with its refresh token rather than running the exchange again.

```bash theme={null}
curl -X POST https://api.jtl-cloud.com/account/identity/act-as-user-token/refresh \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"tenantId\": \"$TENANT_ID\", \"refreshToken\": \"$REFRESH_TOKEN\"}"
```

The bearer is your service account's access token, the same one you used for the exchange. Store the new refresh token and discard the old one.

Rotation re-checks that your app is still installed for the tenant and the capability is still enabled, so uninstalling the app or turning it off ends the delegation. A refresh token cannot outlive your app's access.

<Warning>
  A refresh token is single use. Once rotated, the previous one is invalid, so a stored copy left over from an earlier rotation fails with `400`.
</Warning>

## Token Claims

The on-behalf-of token is a JWT you use as a bearer. Its subject is the user, so services authorize as the user, while the delegation stays visible in the claims.

| Claim | Value |
| - | - |
| `sub` | The user's ID. Services authorize as this user. |
| `act` | The Account Service, which brokered the exchange |
| `aud` | Your app, so the token is narrowed to it |
| `urn:jtl:app_id` | Your app's ID, identifying which app initiated the exchange |
| `urn:jtl:tenant_id` | The tenant you named at exchange time |
| `urn:jtl:kundencenter_id` | The tenant's JTL customer account number |

The tenant is fixed when the token is minted, so a token issued for one tenant cannot be used against another.

## Errors

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

<AccordionGroup>
  <Accordion title="401 Unauthorized on the exchange">
    No service account token reached the endpoint. The exchange authenticates as your app, so your access token belongs in the `Authorization` header of the exchange request itself, not only on the calls you make afterwards.
  </Accordion>

  <Accordion title="403 Forbidden: the token carries no app ID">
    The access token was issued without an app identity behind it. Confirm the app and its service account are registered in the [Partner Portal](https://partner.jtl-cloud.com/), and that the credentials you are using belong to that app.
  </Accordion>

  <Accordion title="403 Forbidden: the capability is off, or the app is not installed">
    Two checks produce this. Confirm **Act on behalf of users** is enabled on your app's service account, and that the merchant has installed your app on the tenant you named.
  </Accordion>

  <Accordion title="403 Forbidden: the user is not a member of the tenant">
    The `userId` and `tenantId` do not belong together. Take both from the same verified app token, where `sub` is the user and `urn:jtl:tenant_id` is their tenant, rather than combining values from different sources.
  </Accordion>

  <Accordion title="400 Bad Request on the refresh call">
    The refresh token is invalid, expired, revoked, or has already been rotated. Rotation invalidates the previous token immediately, so a stored copy from an earlier rotation fails. Run the full exchange again to get a new pair.
  </Accordion>

  <Accordion title="403 Forbidden on an API call after a successful exchange">
    The token is valid, but the call exceeds what the user is permitted to do. An on-behalf-of token is bounded by that user's own JTL-Wawi permissions, so a merchant without write access to a resource cannot write to it through your app. See [Scopes & Permissions](/cloud/guides/essentials/authentication/scopes-permissions#how-scopes-are-enforced).
  </Accordion>
</AccordionGroup>

## What's Next?

<CardGroup cols={2}>
  <Card title="Service Account Authentication" icon="server" href="/cloud/guides/cloud-apps/service-account-authentication">
    Obtain and cache the access token this exchange starts from.
  </Card>

  <Card title="App Token Authentication" icon="key" href="/cloud/guides/cloud-apps/app-token-authentication">
    Verify the app token that tells you which user and tenant to act for.
  </Card>

  <Card title="Scopes & Permissions" icon="shield" href="/cloud/guides/essentials/authentication/scopes-permissions">
    How enforcement differs between an access token and a user-bound token.
  </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>
</CardGroup>
