Skip to main content

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.

Build the Slim 4 backend for a JTL Cloud App using PHP 8 and the built-in sodium_crypto_sign_verify_detached function for Ed25519 signature verification. By the end, the backend runs locally and the frontend’s “Waiting for backend” placeholder turns into a real connection. Stack: PHP 8.1+, Slim 4, vlucas/phpdotenv for environment variables, native ext-sodium for JWT verification.

Prerequisites

You need:
  • A finished frontend from the Build the Frontend page, running locally on http://localhost:5173
  • PHP 8.1 or higher with ext-sodium enabled. Run php --version and php -m | grep sodium to check.
  • Composer for dependency management. Run composer --version to check.

What you’re Building

During setup, your backend verifies that the session token from your frontend is valid and was issued by JTL. To do this:
  • Your backend authenticates with JTL using its client credentials
  • Fetches JTL’s public keys (JWKS)
  • And uses them to verify the session token’s signature
Once verified, the token tells your backend which tenant (merchant) and user is using your app. Your backend can then make tenant-scoped requests to the JTL Cloud API on their behalf.

1. Set up the Project

Create a backend folder alongside the existing frontend folder, then initialise a Composer project inside it.
cd my-jtl-app
mkdir backend
cd backend
composer init --name="my-jtl-app/backend" --type=project --no-interaction
Your project structure now looks like this:
my-jtl-app/
├── frontend/      # From the previous page
└── backend/       # New

2. Install Packages

composer require slim/slim slim/psr7 vlucas/phpdotenv
PackagePurpose
slim/slimThe Slim 4 micro-framework: routing, request and response handling
slim/psr7The PSR-7 implementation Slim uses by default
vlucas/phpdotenvLoads environment variables from a .env file

3. Set up Environment Variables

Create backend/.env:
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
PORT=5273
The frontend’s Vite dev proxy is already configured to forward /api/* requests to localhost:5273, so the PORT value matches that target. Add .env and vendor/ to backend/.gitignore so secrets and dependencies don’t end up in version control:
vendor
.env

4. Build the Auth Helper

The first piece of the backend is a function that authenticates with JTL using your client credentials and returns an access token. This token has two uses: fetching the public keys for session token verification, and making tenant-scoped calls to the JTL Cloud API. Create backend/src/JtlAuth.php:
<?php

namespace App;

class JtlAuth
{
    public const API_BASE_URL = 'https://api.jtl-cloud.com';
    public const AUTH_ENDPOINT = 'https://auth.jtl-cloud.com/oauth2/token';

    private string $clientId;
    private string $clientSecret;

    public function __construct(string $clientId, string $clientSecret)
    {
        if (empty($clientId) || empty($clientSecret)) {
            throw new \RuntimeException('CLIENT_ID and CLIENT_SECRET must be defined in .env');
        }

        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
    }

    public function getJwt(): string
    {
        $authString = base64_encode("{$this->clientId}:{$this->clientSecret}");

        $ch = curl_init(self::AUTH_ENDPOINT);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/x-www-form-urlencoded',
                "Authorization: Basic {$authString}",
            ],
            CURLOPT_POSTFIELDS => http_build_query(['grant_type' => 'client_credentials']),
        ]);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new \RuntimeException("Failed to fetch JWT ({$statusCode}): {$response}");
        }

        $data = json_decode($response, true);
        return $data['access_token'];
    }
}
This encodes the client credentials as Base64, sends a client_credentials grant request to JTL’s auth endpoint, and returns the resulting access token. The token is short-lived, so the function fetches a fresh one on each call. For higher-traffic backends you’d add caching.

5. Build the Session Verifier

With an access token in hand, the backend can fetch the public keys it needs to verify session tokens. The sodium_crypto_sign_verify_detached function verifies Ed25519 signatures directly against the public key. Create backend/src/SessionVerifier.php:
<?php

namespace App;

class SessionVerifier
{
    private JtlAuth $auth;

    public function __construct(JtlAuth $auth)
    {
        $this->auth = $auth;
    }

    public function verify(string $sessionToken): array
    {
        $accessToken = $this->auth->getJwt();

        $ch = curl_init(JtlAuth::API_BASE_URL . '/account/.well-known/jwks.json');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ["Authorization: Bearer {$accessToken}"],
        ]);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new \RuntimeException("Failed to fetch JWKS ({$statusCode})");
        }

        $jwks = json_decode($response, true);
        $publicKey = $this->base64UrlDecode($jwks['keys'][0]['x']);

        return $this->verifyAndDecode($sessionToken, $publicKey);
    }

    private function verifyAndDecode(string $token, string $publicKey): array
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            throw new \InvalidArgumentException('Invalid JWT format. Expected 3 dot-separated parts.');
        }

        [$header, $payload, $signature] = $parts;

        $signedData = "{$header}.{$payload}";
        $signatureBytes = $this->base64UrlDecode($signature);

        if (!sodium_crypto_sign_verify_detached($signatureBytes, $signedData, $publicKey)) {
            throw new \RuntimeException('Invalid token signature.');
        }

        $decodedPayload = json_decode($this->base64UrlDecode($payload), true);

        if (($decodedPayload['exp'] ?? 0) < time()) {
            throw new \RuntimeException('Token has expired.');
        }

        $decodedHeader = json_decode($this->base64UrlDecode($header), true);

        return [
            'exp' => $decodedPayload['exp'],
            'userId' => $decodedPayload['userId'],
            'tenantId' => $decodedPayload['tenantId'],
            'tenantSlug' => $decodedPayload['tenantSlug'] ?? null,
            'kid' => $decodedHeader['kid'] ?? '',
        ];
    }

    private function base64UrlDecode(string $input): string
    {
        $padded = strtr($input, '-_', '+/');
        $remainder = strlen($padded) % 4;
        if ($remainder > 0) {
            $padded .= str_repeat('=', 4 - $remainder);
        }
        return base64_decode($padded);
    }
}
The class fetches the JWKS document, pulls out the first public key, and verifies the session token’s signature using sodium_crypto_sign_verify_detached. If the signature is valid and the token isn’t expired, the method returns the decoded payload as an associative array.
In production, select the correct key by matching the kid in the session token’s header against the keys in the JWKS response. This example uses the first key for simplicity, which works as long as the JWKS only contains one key.

6. Build the Connect Tenant Endpoint

Now connect the verifier into a Slim route. The frontend’s shell layout sends the session token to /api/connect-tenant via the X-Session-ID header. Create backend/public/index.php:
<?php

use App\JtlAuth;
use App\SessionVerifier;
use Dotenv\Dotenv;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

require __DIR__ . '/../vendor/autoload.php';

Dotenv::createImmutable(__DIR__ . '/..')->load();

$auth = new JtlAuth($_ENV['CLIENT_ID'] ?? '', $_ENV['CLIENT_SECRET'] ?? '');
$verifier = new SessionVerifier($auth);

$app = AppFactory::create();
$app->addBodyParsingMiddleware();

// Allow the Vite dev server to call the API during development
$app->add(function (Request $request, $handler) {
    $response = $handler->handle($request);
    return $response
        ->withHeader('Access-Control-Allow-Origin', 'http://localhost:5173')
        ->withHeader('Access-Control-Allow-Headers', 'X-Session-ID, Authorization, Content-Type')
        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
});

$app->options('/{routes:.+}', fn(Request $req, Response $res) => $res);

$app->get('/api/connect-tenant', function (Request $request, Response $response) use ($verifier) {
    $sessionToken = $request->getHeaderLine('X-Session-ID');

    if (empty($sessionToken)) {
        $response->getBody()->write(json_encode(['error' => 'Session token is required']));
        return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
    }

    try {
        $payload = $verifier->verify($sessionToken);

        // In a production app, you'd store the tenant connection in your database here
        error_log("Tenant connected: {$payload['tenantId']}");

        $response->getBody()->write(json_encode([
            'success' => true,
            'tenantId' => $payload['tenantId'],
            'userId' => $payload['userId'],
            'tenantSlug' => $payload['tenantSlug'],
        ]));
        return $response->withHeader('Content-Type', 'application/json');
    } catch (\Throwable $e) {
        error_log("Connection failed: {$e->getMessage()}");
        $response->getBody()->write(json_encode(['error' => 'Failed to verify session token']));
        return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
    }
});

$app->run();
The bootstrap loads .env, instantiates JtlAuth with the credentials, and passes that into SessionVerifier. Both instances are then captured by the route closure via use ($verifier) and shared across requests. The CORS middleware allows requests from the Vite dev server on port 5173. The dev proxy in vite.config.ts already routes frontend fetch('/api/...') calls to this backend, but the CORS headers are a useful safety net during development. See the Tenant Mapping section for more on managing tenants in production.

7. Configure Composer Autoloading

Open backend/composer.json and add a psr-4 autoload mapping for the App\ namespace:
{
    "name": "my-jtl-app/backend",
    "type": "project",
    "require": {
        "slim/slim": "^4.15",
        "slim/psr7": "^1.7",
        "vlucas/phpdotenv": "^5.6"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}
Then regenerate the autoloader so Composer picks up the new mapping:
composer dump-autoload
This tells Composer that any class in the App\ namespace lives under the src/ directory, which is how JtlAuth and SessionVerifier get loaded when index.php references them.

8. Run the Backend

Start the dev server:
php -S localhost:5273 -t public
You should see:
PHP 8.x.x Development Server (http://localhost:5273) started
In a second terminal, send a request with a fake session token to confirm the route is reachable:
curl http://localhost:5273/api/connect-tenant \
  -H "X-Session-ID: fake-token"
You should get back a 401 response with {"error":"Failed to verify session token"}. That’s the expected outcome for an invalid token. A real session token from the App Shell will follow the same path and succeed.

Common Issues

This error means Composer’s autoloader doesn’t know where to find the class. The most common cause is forgetting to regenerate the autoload files after adding the psr-4 mapping to composer.json.Running composer dump-autoload from the backend/ directory rebuilds vendor/autoload.php to include any new namespace mappings. The error should clear after the next request.
This error means the backend started but couldn’t find your credentials in the environment. The most common cause is that .env is sitting in the wrong directory, or that the Dotenv::createImmutable() call is pointing at the wrong path.
This error means PHP’s sodium extension isn’t enabled. The extension ships with PHP 7.2 and later and is enabled by default on most distributions, but some minimal PHP installations (particularly Docker images and shared hosts) ship without it.Running php -m | grep sodium will confirm whether the extension is loaded. If the command returns nothing, the extension needs to be enabled.
A 401 from the auth endpoint means the credentials are not valid.If you are still using placeholder values, this is expected. Real credentials are provided after registering your app in the Partner Portal.If you have already registered:
  • check for typos or extra spaces in .env
  • restart the dev server after making changes
Environment variables are only read at startup, so updates to .env require a restart.
A CORS error usually means the request reached the backend but the browser blocked the response because the origin didn’t match what the backend allows. The CORS middleware in index.php is configured to allow http://localhost:5173, which matches the default Vite dev server port.If the Vite dev server is running on a different port (for example, because port 5173 was already in use and Vite picked 5174 instead), the browser will see a mismatch. Updating the Access-Control-Allow-Origin value in index.php to match the actual Vite port, or freeing up port 5173, should resolve it.

Next: Connect and Fetch Data

The backend verifies session tokens and is ready to call the JTL Cloud API. The remaining work is registering your app with JTL to get real credentials, installing the app in the JTL Hub, and pulling product data from JTL-Wawi:

Connect and Fetch Data

Register your app, install it in the JTL Hub, and fetch products from JTL-Wawi.