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

# Using Platform APIs

> Call the JTL Cloud and JTL-Wawi APIs from your backend using REST for authentication and GraphQL for ERP data.

The JTL platform exposes two API surfaces, each serving a different purpose:

| API             | Purpose                                                                      | Format                         |
| --------------- | ---------------------------------------------------------------------------- | ------------------------------ |
| **REST API**    | Authentication, token management, and account operations                     | Standard REST (JSON over HTTP) |
| **GraphQL API** | Querying and mutating ERP data (items, categories, customers, orders, stock) | GraphQL (single endpoint)      |

In practice, your backend uses the REST API to obtain access tokens, then uses those tokens to query and mutate ERP data through GraphQL.

For the conceptual foundations (pagination, error handling, versioning), see the [Essentials](/guides/essentials/common-patterns/error-handling) section.

## Required Headers

Every API request to the JTL Cloud Platform requires a specific set of headers. Missing or incorrect headers result in `401` or `400` errors.

| Header          | Value                   | Required                 | Description                                                                           |
| --------------- | ----------------------- | ------------------------ | ------------------------------------------------------------------------------------- |
| `Authorization` | `Bearer <access_token>` | Yes                      | JWT access token from the client credentials flow                                     |
| `X-Tenant-ID`   | `<tenantId>`            | Yes                      | Identifies which merchant's data to access. Get this from the verified session token. |
| `Content-Type`  | `application/json`      | Yes (for POST/mutations) | Request body format                                                                   |

<Note>
  The `X-Tenant-ID` is what scopes your request to a specific merchant. The tenant ID determines whose data you're reading or writing.
</Note>

<Note>
  C# samples target .NET 8 with implicit usings enabled and use raw string literals (C# 11+). PHP samples target PHP 8.1+ and require the `guzzlehttp/guzzle` Composer dependency. TypeScript samples assume a modern Node.js or browser environment with `fetch` available globally. These examples use raw HTTP requests. If your project already uses HTTP or GraphQL client libraries, the same headers and payload structure apply.
</Note>

## REST API

The REST API handles authentication and account-level operations. You already use it to obtain access tokens (see [Authentication & Login](/guides/cloud-apps/authentication-login)). It also provides endpoints for account management, JWKS key retrieval, and other platform services.

### Base URL

REST endpoints use URL-path versioning:

```
https://api.jtl-cloud.com/erp/v2/{endpoint}
```

Always target `/erp/v2/` for new integrations. See [Versioning](/guides/essentials/common-patterns/versioning) for the full version history.

### Example: Token Request

This is the most common REST call your app makes. See [Authentication & Login](/guides/cloud-apps/authentication-login#client-credentials-getting-an-access-token) for the full implementation with caching.

```bash theme={null}
curl -X POST https://auth.jtl-cloud.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
  -d "grant_type=client_credentials"
```

### Example: JWKS Retrieval

Fetch the public keys used to verify session tokens:

```bash theme={null}
curl -X GET https://api.jtl-cloud.com/account/.well-known/jwks.json \
  -H "Authorization: Bearer <access_token>"
```

**What this does:** Returns the JSON Web Key Set (JWKS) containing the public keys your backend uses to verify session tokens from the AppBridge. Cache this response and refresh it when key verification fails.

***

## GraphQL API

All ERP data operations (reading items, creating categories, updating customers, querying orders) go through the GraphQL API. It provides a single endpoint for both queries (reads) and mutations (writes).

### Endpoint

```
POST https://api.jtl-cloud.com/erp/v2/graphql
```

All GraphQL requests are `POST` requests to this single URL, regardless of whether you're reading or writing data.

### Making a Request

Every GraphQL request has the same structure: an `operationName`, a `query` string, and optional `variables`. The shape is identical across languages, only the syntax for sending the HTTP request differs.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.jtl-cloud.com/erp/v2/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
      "X-Tenant-ID": tenantId,
    },
    body: JSON.stringify({
      operationName: "MyOperation",
      query: `query MyOperation { ... }`,
      variables: { /* ... */ },
    }),
  });

  const { data, errors } = await response.json();
  ```

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

  var payload = new
  {
      operationName = "MyOperation",
      query = "query MyOperation { ... }",
      variables = new { /* ... */ },
  };

  var request = new HttpRequestMessage(
      HttpMethod.Post,
      "https://api.jtl-cloud.com/erp/v2/graphql"
  );
  request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
  request.Headers.Add("X-Tenant-ID", tenantId);
  request.Content = new StringContent(
      JsonSerializer.Serialize(payload),
      Encoding.UTF8,
      "application/json"
  );

  var response = await HttpClient.SendAsync(request);
  var json = await response.Content.ReadAsStringAsync();
  var result = JsonSerializer.Deserialize<JsonElement>(json);
  ```

  ```php PHP theme={null}
  <?php
  // Inside a method on a class that has $this->httpClient (Guzzle)
  $response = $this->httpClient->post('https://api.jtl-cloud.com/erp/v2/graphql', [
      'headers' => [
          'Content-Type' => 'application/json',
          'Authorization' => "Bearer {$accessToken}",
          'X-Tenant-ID' => $tenantId,
      ],
      'json' => [
          'operationName' => 'MyOperation',
          'query' => 'query MyOperation { ... }',
          'variables' => (object) [/* ... */],
      ],
  ]);

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

  ```bash cURL theme={null}
  curl -X POST https://api.jtl-cloud.com/erp/v2/graphql \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "X-Tenant-ID: $TENANT_ID" \
    -d '{
      "operationName": "MyOperation",
      "query": "query MyOperation { ... }",
      "variables": {}
    }'
  ```
</CodeGroup>

**What this does:** Sends a GraphQL operation to the ERP API with your access token and tenant ID. The response contains a `data` object with the results, or an `errors` array if something went wrong.

<Note>
  Throughout this guide, GraphQL query and mutation strings are shown in plain GraphQL syntax. The string itself is the same across languages; only how you embed it (a template literal in TypeScript, a verbatim string in C#, a heredoc in PHP) differs. The variables object follows the JSON serialization conventions of your language.
</Note>

## Querying Data

Queries read data from the ERP without modifying it. Use them to fetch items, customers, orders, categories, and other resources.

The examples in this section use the `query()` / `QueryAsync()` / `JtlGraphQL::query()` helper built in [Building a Reusable API Client](#building-a-reusable-api-client).

The GraphQL operation itself is language-agnostic. Only the variables object differs depending on your language's JSON syntax and conventions.

### Basic Query

Fetch a list of products with their SKU and name:

```graphql theme={null}
query GetERPItems($first: Int, $order: [ItemListItemSortInput!]) {
  QueryItems(first: $first, order: $order) {
    nodes {
      id
      sku
      name
    }
    totalCount
  }
}
```

Call it with variables for page size and sort order:

```typescript theme={null}
const items = await query(tenantId, "GetERPItems", ITEMS_QUERY, {
  first: 10,
  order: [{ name: "ASC" }],
});
```

**What this does:** Fetches the first 10 items sorted alphabetically by name. The response includes a `nodes` array with each item's `id`, `sku`, and `name`, plus a `totalCount` of all matching items.

<Accordion title="Example response">
  ```json theme={null}
    {
      "data": {
        "QueryItems": {
          "nodes": [
            {
              "id": "895d2d4d-4ed4-44c7-ac61-ebec01000000",
              "sku": "AR2016041-VKO",
              "name": "Men's T-shirt"
            },
            {
              "id": "895d2d4d-4ed4-44c7-ac61-ebec06000000",
              "sku": "AR2016041-002",
              "name": "Men's T-shirt orange S"
            }
          ],
          "totalCount": 674
        }
      }
    }
  ```
</Accordion>

### Requesting Specific Fields

One of GraphQL's key advantages is that you only fetch the fields you need. This reduces payload size and improves performance.

A minimal query with just IDs and names:

```graphql theme={null}
query GetItemsMinimal {
  QueryItems(first: 50) {
    nodes {
      id
      name
    }
    totalCount
  }
}
```

A detailed query with pricing and stock data:

```graphql theme={null}
query GetItemsDetailed {
  QueryItems(first: 50) {
    nodes {
      id
      sku
      name
      gtin
      salesPriceNet
      averagePurchasePriceNet
      stockTotal
      stockInOrders
      manufacturerName
      productGroupName
      taxClassName
    }
    totalCount
  }
}
```

The same `query()` helper handles both, only the operation string changes.

### Sorting

Control the order of results using the `order` variable. Pass an array of sort objects with the field name as the key and `ASC` or `DESC` as the value.

```typescript theme={null}
// Alphabetical by name
const variables = { first: 20, order: [{ name: "ASC" }] };

// Reverse alphabetical by SKU
const variables = { first: 20, order: [{ sku: "DESC" }] };
```

<Note>
  In C#, build the variables with `new { first = 20, order = new[] { new { name = "ASC" } } }`. In PHP, use `['first' => 20, 'order' => [['name' => 'ASC']]]`.
</Note>

### Filtering

Use the `where` variable to filter results based on field conditions. Filters use a structured input type specific to each query.

```typescript theme={null}
const variables = {
  first: 20,
  where: {
    name: { contains: "T-shirt" },
  },
};
```

Filter operators vary by field type, such as `contains` and `eq` for strings, `gte` and `lte` for numbers, `in` for enum-like fields. See the [GraphQL Playground](/api-reference/graphql-playground) for the full schema of available operators on each query.

### Pagination

The GraphQL API uses cursor-based pagination. Use `first` to set the page size and `after` to fetch subsequent pages.

Add `pageInfo` to your query to get pagination metadata:

```graphql theme={null}
query GetERPItems($first: Int, $after: String) {
  QueryItems(first: $first, after: $after) {
    nodes {
      id
      sku
      name
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
```

Then loop by passing the previous response's `endCursor` as `after` until `hasNextPage` is `false`:

```typescript theme={null}
let cursor: string | null = null;

while (true) {
  const page = await query<ItemsResponse>(tenantId, "GetERPItems", ITEMS_QUERY, {
    first: 20,
    after: cursor,
  });

  // Process page.QueryItems.nodes here

  if (!page.QueryItems.pageInfo.hasNextPage) break;
  cursor = page.QueryItems.pageInfo.endCursor;
}
```

| Field         | Description                                               |
| ------------- | --------------------------------------------------------- |
| `hasNextPage` | `true` if more results exist beyond this page             |
| `endCursor`   | Opaque cursor string to pass as `after` for the next page |
| `totalCount`  | Total number of matching items across all pages           |

For the full pagination pattern including REST-style page-based pagination, see [Pagination](/guides/essentials/common-patterns/pagination).

## Mutating Data

Mutations create, update, or delete ERP data. They follow the same request structure as queries but use the `mutation` keyword. Use the same `query()` helper as it handles both.

### Creating a Resource

Create a new category in the ERP:

```graphql theme={null}
mutation CreateCategory($request: CreateCategoryCommandRequestInput) {
  CreateCategory(request: $request) {
    categoryId
  }
}
```

Call it with the category details:

```typescript theme={null}
const result = await query(tenantId, "CreateCategory", CREATE_CATEGORY_MUTATION, {
  request: {
    name: "Summer Collection",
    sortNumber: 1,
    parentId: null,
  },
});

const categoryId = result.CreateCategory.categoryId;
```

**What this does:** Creates a new top-level category named "Summer Collection". The response returns the `categoryId` of the newly created resource. Set `parentId` to an existing category ID to create a subcategory.

### Updating a Resource

Update an existing category:

```graphql theme={null}
mutation UpdateCategory($request: UpdateCategoryCommandRequestInput) {
  UpdateCategory(request: $request)
}
```

Call it with the resource ID and the fields you want to change:

```typescript theme={null}
await query(tenantId, "UpdateCategory", UPDATE_CATEGORY_MUTATION, {
  request: {
    id: "cat-abc-123",
    name: "Winter Collection",
    sortNumber: 2,
  },
});
```

**What this does:** Updates the category's name and sort order. The `id` field is required to identify which resource to update. Only the fields you include in the request are modified; omitted fields remain unchanged. The mutation returns `true` on success.

## Handling Responses

Every GraphQL response has the same top-level structure:

```json theme={null}
{
  "data": { ... },
  "errors": [ ... ]
}
```

### Success

A successful response contains the `data` object with your requested fields. The `errors` field is either absent or an empty array.

### Errors

GraphQL errors are returned in the `errors` array, even when the HTTP status is `200`. Always check for errors in the response body, not just the status code.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const { data, errors } = await response.json();

  if (errors?.length) {
    // Handle errors (log, retry, or surface to the user)
    throw new Error(`GraphQL error: ${errors.map(e => e.message).join(", ")}`);
  }

  if (data) {
    // Process successful response
  }
  ```

  ```csharp C# theme={null}
  var json = await response.Content.ReadAsStringAsync();
  var result = JsonSerializer.Deserialize<JsonElement>(json);

  if (result.TryGetProperty("errors", out var errorsElement) &&
      errorsElement.ValueKind == JsonValueKind.Array &&
      errorsElement.GetArrayLength() > 0)
  {
      var messages = errorsElement.EnumerateArray()
          .Select(e => e.GetProperty("message").GetString())
          .ToArray();
      throw new InvalidOperationException(
          $"GraphQL error: {string.Join(", ", messages)}"
      );
  }

  if (result.TryGetProperty("data", out var data))
  {
      // Process successful response
  }
  ```

  ```php PHP theme={null}
  $result = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

  if (!empty($result['errors'])) {
      $messages = array_map(fn($e) => $e['message'], $result['errors']);
      throw new \RuntimeException('GraphQL error: ' . implode(', ', $messages));
  }

  if (isset($result['data'])) {
      // Process successful response
  }
  ```
</CodeGroup>

**What this does:** Checks for errors in the GraphQL response before processing data. Unlike REST APIs where errors are indicated by HTTP status codes, GraphQL can return partial data alongside errors. Always inspect both fields.

For a complete reference on error formats and retry strategies, see [Error Handling](/guides/essentials/common-patterns/error-handling).

## Building a Reusable API Client

As your app grows, you'll want a helper that handles headers, authentication, and error checking in one place.

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

  const GRAPHQL_ENDPOINT = 'https://api.jtl-cloud.com/erp/v2/graphql';

  interface GraphQLResponse<T> {
    data: T | null;
    errors?: Array<{ message: string }>;
  }

  export async function query<T>(
    tenantId: string,
    operationName: string,
    queryString: string,
    variables?: Record<string, unknown>,
  ): Promise<T> {
    const accessToken = await getCachedAccessToken();

    const response = await fetch(GRAPHQL_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${accessToken}`,
        'X-Tenant-ID': tenantId,
      },
      body: JSON.stringify({ operationName, query: queryString, variables }),
    });

    if (!response.ok) {
      throw new Error(`API request failed (${response.status})`);
    }

    const result: GraphQLResponse<T> = await response.json();

    if (result.errors?.length) {
      throw new Error(
        `GraphQL error: ${result.errors.map(e => e.message).join(', ')}`,
      );
    }

    if (!result.data) {
      throw new Error('No data returned from GraphQL API');
    }

    return result.data;
  }
  ```

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

  public static class JtlGraphQL
  {
      private const string Endpoint = "https://api.jtl-cloud.com/erp/v2/graphql";
      private static readonly HttpClient HttpClient = new();

      public static async Task<T> QueryAsync<T>(
          string tenantId,
          string operationName,
          string queryString,
          object? variables = null
      )
      {
          var accessToken = await TokenCache.GetCachedAccessTokenAsync();

          var payload = new
          {
              operationName,
              query = queryString,
              variables = variables ?? new { },
          };

          var request = new HttpRequestMessage(HttpMethod.Post, Endpoint);
          request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
          request.Headers.Add("X-Tenant-ID", tenantId);
          request.Content = new StringContent(
              JsonSerializer.Serialize(payload),
              Encoding.UTF8,
              "application/json"
          );

          var response = await HttpClient.SendAsync(request);

          if (!response.IsSuccessStatusCode)
              throw new HttpRequestException($"API request failed ({(int)response.StatusCode})");

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

          if (root.TryGetProperty("errors", out var errors) &&
              errors.ValueKind == JsonValueKind.Array &&
              errors.GetArrayLength() > 0)
          {
              var messages = errors.EnumerateArray()
                  .Select(e => e.GetProperty("message").GetString())
                  .ToArray();
              throw new InvalidOperationException(
                  $"GraphQL error: {string.Join(", ", messages)}"
              );
          }

          if (!root.TryGetProperty("data", out var data) || data.ValueKind == JsonValueKind.Null)
              throw new InvalidOperationException("No data returned from GraphQL API");

          return JsonSerializer.Deserialize<T>(data.GetRawText())
              ?? throw new InvalidOperationException("Failed to deserialize GraphQL data");
      }
  }
  ```

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

  namespace App\Jtl;

  use GuzzleHttp\Client;
  use RuntimeException;

  final class JtlGraphQL
  {
      private const ENDPOINT = 'https://api.jtl-cloud.com/erp/v2/graphql';

      private static ?Client $httpClient = null;

      public static function query(
          string $tenantId,
          string $operationName,
          string $queryString,
          ?array $variables = null
      ): array {
          $accessToken = TokenCache::getCachedAccessToken();

          $response = self::httpClient()->post(self::ENDPOINT, [
              'headers' => [
                  'Content-Type' => 'application/json',
                  'Authorization' => "Bearer {$accessToken}",
                  'X-Tenant-ID' => $tenantId,
              ],
              'json' => [
                  'operationName' => $operationName,
                  'query' => $queryString,
                  'variables' => $variables ?? (object) [],
              ],
              'http_errors' => false,
          ]);

          if ($response->getStatusCode() >= 400) {
              throw new RuntimeException("API request failed ({$response->getStatusCode()})");
          }

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

          if (!empty($result['errors'])) {
              $messages = array_map(fn($e) => $e['message'], $result['errors']);
              throw new RuntimeException('GraphQL error: ' . implode(', ', $messages));
          }

          if (!isset($result['data'])) {
              throw new RuntimeException('No data returned from GraphQL API');
          }

          return $result['data'];
      }

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

**What this does:** Wraps the common GraphQL request pattern into a reusable function. It handles access token caching, sets all required headers, checks for both HTTP-level and GraphQL-level errors, and returns parsed data. Use it throughout your app instead of repeating the request boilerplate.

### Using the Client

<CodeGroup>
  ```typescript TypeScript theme={null}
  // Querying items
  interface ItemsResponse {
    QueryItems: {
      nodes: Array<{ id: string; sku: string; name: string }>;
      totalCount: number;
    };
  }

  const items = await query<ItemsResponse>(
    tenantId,
    'GetERPItems',
    `query GetERPItems($first: Int) {
      QueryItems(first: $first) {
        nodes { id sku name }
        totalCount
      }
    }`,
    { first: 10 },
  );

  // items.QueryItems.nodes is fully typed

  // Creating a category
  interface CreateCategoryResponse {
    CreateCategory: { categoryId: string };
  }

  const result = await query<CreateCategoryResponse>(
    tenantId,
    'CreateCategory',
    `mutation CreateCategory($request: CreateCategoryCommandRequestInput) {
      CreateCategory(request: $request) { categoryId }
    }`,
    { request: { name: 'Summer Collection', sortNumber: 1 } },
  );

  const categoryId = result.CreateCategory.categoryId;
  ```

  ```csharp C# theme={null}
  // Querying items
  public record Item(string Id, string Sku, string Name);
  public record ItemsPage(Item[] Nodes, int TotalCount);
  public record ItemsResponse(ItemsPage QueryItems);

  var items = await JtlGraphQL.QueryAsync<ItemsResponse>(
      tenantId,
      "GetERPItems",
      """
      query GetERPItems($first: Int) {
        QueryItems(first: $first) {
          nodes { id sku name }
          totalCount
        }
      }
      """,
      new { first = 10 }
  );

  // items.QueryItems.Nodes is fully typed

  // Creating a category
  public record CreateCategoryResult(string CategoryId);
  public record CreateCategoryResponse(CreateCategoryResult CreateCategory);

  var result = await JtlGraphQL.QueryAsync<CreateCategoryResponse>(
      tenantId,
      "CreateCategory",
      """
      mutation CreateCategory($request: CreateCategoryCommandRequestInput) {
        CreateCategory(request: $request) { categoryId }
      }
      """,
      new { request = new { name = "Summer Collection", sortNumber = 1 } }
  );

  var categoryId = result.CreateCategory.CategoryId;
  ```

  ```php PHP theme={null}
  <?php
  // Querying items
  $items = JtlGraphQL::query(
      $tenantId,
      'GetERPItems',
      <<<'GRAPHQL'
      query GetERPItems($first: Int) {
        QueryItems(first: $first) {
          nodes { id sku name }
          totalCount
        }
      }
      GRAPHQL,
      ['first' => 10]
  );

  $nodes = $items['QueryItems']['nodes'];

  // Creating a category
  $result = JtlGraphQL::query(
      $tenantId,
      'CreateCategory',
      <<<'GRAPHQL'
      mutation CreateCategory($request: CreateCategoryCommandRequestInput) {
        CreateCategory(request: $request) { categoryId }
      }
      GRAPHQL,
      ['request' => ['name' => 'Summer Collection', 'sortNumber' => 1]]
  );

  $categoryId = $result['CreateCategory']['categoryId'];
  ```
</CodeGroup>

***

## What's Next

<CardGroup cols={2}>
  <Card title="GraphQL Playground" icon="share-2" href="/api-reference/graphql-playground">
    Try queries and mutations interactively with autocomplete and schema
    docs.
  </Card>

  <Card title="Handling Webhooks" icon="bell" href="/guides/cloud-apps/handling-webhooks">
    Respond to lifecycle events and AppBridge messages in your app.
  </Card>

  <Card title="Error Handling" icon="triangle-alert" href="/guides/essentials/common-patterns/error-handling">
    Understand error formats, retry strategies, and validation errors.
  </Card>

  <Card title="Rate Limiting" icon="gauge" href="/guides/essentials/common-patterns/rate-limiting">
    Stay within request limits and handle throttling gracefully.
  </Card>

  <Card title="Best Practices" icon="star" href="/guides/cloud-apps/best-practices">
    Production patterns for API usage, caching, and performance.
  </Card>
</CardGroup>
