# ERP (JTL-Wawi) Source: https://developer.jtl-software.com/api-reference/erp REST and GraphQL access to JTL-Wawi business data The ERP API lets you read and manipulate data in JTL-Wawi: orders, products, customers, invoices, inventory, and more. It is available as a REST API (all versions) and, starting with v2.0, as a GraphQL API. Auth, headers, deployment variants, and how to make your first call. Available in v2.0. Browse the schema and run queries interactively. ## OpenAPI Spec Download the spec for any supported version. Import into Postman, Insomnia, or generate a client. | Version | Status | Spec | | ------- | ------------------- | ---------------------------------- | | v2.1 | Pre-release | [Download JSON](/openapi/2.1.json) | | v2.0 | Latest, recommended | [Download JSON](/openapi/2.0.json) | | v1.4 | Supported | [Download JSON](/openapi/1.4.json) | | v1.3 | Supported | [Download JSON](/openapi/1.3.json) | | v1.2 | Supported | [Download JSON](/openapi/1.2.json) | | v1.1 | Supported | [Download JSON](/openapi/1.1.json) | | v1.0 | Supported | [Download JSON](/openapi/1.0.json) | # GraphQL playground Source: https://developer.jtl-software.com/api-reference/graphql-playground Interactive GraphQL IDE to explore and test the JTL Platform API

Explore the JTL ERP API using GraphQL. Use the schema explorer on the left to browse available queries and mutations, or type directly in the editor. Log in to run queries against your own ERP instance.

Best Viewed on Desktop

The GraphQL playground is a full interactive IDE built for larger screens. Open this page on a desktop or tablet to browse the schema and run queries.

# Build with AI Source: https://developer.jtl-software.com/get-started/build-with-ai Build faster on JTL platform by consuming the docs in your AI-coding tools (like Cursor, Claude, etc) JTL provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that gives AI tools direct access to the full developer documentation, so they can answer questions, generate code, and help you build Cloud Apps with accurate, up-to-date information. ## Connect to your AI Tool Choose your AI coding tool below and follow the setup instructions. Add the JTL docs MCP server with a single command: ```bash theme={null} claude mcp add --transport http jtl-docs https://developer.jtl-software.com/mcp ``` Restart Claude Code, then run `/mcp`. You'll see `jtl-docs · ✔ connected` as part of the MCP servers. Add the following to your project's `.vscode/mcp.json` (create the file if it doesn't exist): ```json theme={null} { "servers": { "jtl-docs": { "type": "http", "url": "https://developer.jtl-software.com/mcp" } } } ``` Restart VS Code and Copilot will pick up the MCP server. Start Copilot CLI and then run the command below: ```bash theme={null} /mcp add ``` Copilot will open an interactive form asking for the MCP server details. Fill in the following: * **Server Name:** `jtl-docs` * **Server Type:** Press 3 to select HTTP * **URL:** `https://developer.jtl-software.com/mcp` Use the **Tab** key to navigate and **ctrl+s** to save. Once it's saved, run `/mcp show`. You'll see `✓ jtl-docs` in the list of available MCP servers. Open **Cursor Settings** > **Tools & MCPs** > **Add Custom MCP**. Then paste the following: ```json theme={null} { "mcpServers": { "jtl-docs": { "type": "http", "url": "https://developer.jtl-software.com/mcp" } } } ``` Alternatively, add it to your project's `.cursor/mcp.json` (create the file if it doesn’t exist): ```json theme={null} { "mcpServers": { "jtl-docs": { "type": "http", "url": "https://developer.jtl-software.com/mcp" } } } ``` Open **Devin Settings > Devin Local > Open Devin MCP marketplace > Add custom MCP** and add a new server, or add it manually by editing `~/.codeium/windsurf/mcp_config.json`: ```json theme={null} { "mcpServers": { "jtl-docs": { "type": "http", "url": "https://developer.jtl-software.com/mcp" } } } ``` You can also connect directly from any page in these docs. Click the contextual menu (top of any page) and select **Connect to Cursor** or **Connect to VS Code** to add the MCP server with one click. ```text icon="sparkles" AI Assistant Prompt theme={null} Help me build a JTL Cloud App from scratch. Use the jtl-docs MCP server for any platform details. Follow the From Scratch quickstart: 1. Scaffold the frontend with Vite, TanStack Router, AppBridge, and Platform UI, with the AppBridge provider scoped to the shell route group. 2. Set up a backend (Node, C#, or PHP) that verifies the session token and exposes the connect-tenant and items endpoints. 3. Generate a manifest.json with configurationUrl and the correct capabilities and scopes. 4. Suggest relevant next steps such as submitting to the App Store. ``` ## What the AI Can Do Once connected, your AI tool can: **Answer platform questions.** Ask "How do I verify a session token from AppBridge?" and the AI will pull the exact implementation from the authentication docs. **Generate manifest files.** Describe what your app does and the AI will generate a complete `manifest.json` with the correct capabilities, scopes, and lifecycle URLs. **Write API integration code.** Ask "Write a GraphQL query to fetch the first 20 items sorted by SKU" and the AI will generate the query, headers, and fetch logic based on the actual API docs. **Debug authentication issues.** Paste an error message and the AI can reference the troubleshooting docs to suggest a fix. **Scaffold entire apps.** Ask the AI to create a Cloud App with a specific feature, and it will follow the architecture patterns from the docs (AppBridge provider, session verification, API client). ## Tips for Better Results **Be specific about what you're building.** "Help me build a JTL Cloud App" is vague. "Create a panel app that shows customer order history in the customer sidebar" gives the AI enough context to generate accurate code. **Reference JTL concepts by name.** Use terms like "AppBridge," "manifest.json," "session token," "JTL-Wawi GraphQL API," or "lifecycle hooks." The AI will map these to the correct documentation pages. **Ask follow-up questions.** After the AI generates code, ask "Does this follow the recommended AppBridge initialization pattern?" The AI will check its answer against the docs. **Combine with the quickstarts.** Start with the [From Template](/get-started/quick-start/from-template) or [From Scratch](/get-started/quick-start/from-scratch) quickstart to get a running app, then use AI to extend it with features. ## Other Ways to Use the Docs with AI Beyond the MCP server, there are a few more ways to feed JTL docs into AI tools: **Copy as Markdown.** On any page, press `Cmd+C` / `Ctrl+C` or select **Copy** from the contextual menu to copy the page content as Markdown. Paste it directly into ChatGPT, Claude, or any other AI tool. **Claude and ChatGPT links.** Select **Open in Claude** or **Open in ChatGPT** from the contextual menu on any page. This opens the AI tool with the page content pre-loaded. **llms.txt.** The docs site hosts an `llms.txt` file that AI crawlers use to index the documentation. This helps general-purpose AI tools like ChatGPT and Perplexity get accurate answers about JTL's platform. *** ## What's Next Clone the sample app and get a running Cloud App in 15 minutes. Build a Cloud App step by step with React and your favourite backend (Node.js, C#, or PHP). Understand how Cloud Apps are structured before you start building. Call the Cloud ERP REST and GraphQL APIs from your app. # Create a Developer Account Source: https://developer.jtl-software.com/get-started/create-developer-account Set up your development environment in three connected steps. Takes about 10 minutes. To build on the JTL platform, you'll work across four connected environments. Each one plays a different role in your development workflow. Three of them are needed to start building, and the fourth comes in when you're ready to test your app. This guide walks you through setting them up in one go. It takes about 10 minutes. ## Your Development Environment Here's what each environment does and why you need it: | Environment | What you'll use it for | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **[JTL-Wawi](/guides/erp-integration/integration-overview)** | The ERP system where merchant data lives: products, orders, customers, inventory. Your app reads and writes this data through APIs or GraphQL. | | **[Partner Portal](https://partner.jtl-cloud.com/)** | Where you create and manage your apps. | | **[JTL Hub](https://hub.jtl-cloud.com/)** | A dedicated environment where you can install and test your apps, verify the connection to JTL-Wawi, and manage the app lifecycle before going live. | | **[ERP Cloud](https://erp.jtl-cloud.com/)** | The web-based interface where your installed app actually runs. Currently in development, with MVP release coming soon. You can already use it to test your app and validate how it integrates. See [Test your App](/get-started/test-your-app). | **JTL-Wawi** is the ERP itself (the data), **Partner Portal** is where you create and manage your apps, **JTL Hub** is where you install, and **ERP Cloud** is where your app runs for the merchant. Here's how they connect: ```mermaid theme={null} flowchart LR subgraph You["👤 Developer"] direction TB end subgraph Create["1 · Partner Portal"] direction TB PP["**Create & configure apps** Manage credentials Handle submissions & payouts"] end subgraph Install["2 · JTL Hub"] direction TB HC["**Sandbox environment** Install & test apps Manage app lifecycle"] end subgraph Run["3 · ERP Cloud"] direction TB EP["**App runtime** Where your installed app actually renders"] end subgraph Data["JTL-Wawi"] direction TB WW["**ERP system** Products, orders, customers, inventory & shipping"] end subgraph Ship["🚀 App Store"] direction TB ES["Distribute to 50,000+ merchants"] end You --> Create Create -- "App credentials" --> Install Install -- "Launches app" --> Run Run -- "APIs" --> Data Install -- "Submit" --> Ship style Create fill:#FFF2EB,stroke:#FB581F,stroke-width:2px,color:#0B1B45 style Install fill:#E8F4FF,stroke:#89D2FF,stroke-width:2px,color:#0B1B45 style Run fill:#E8F4FF,stroke:#89D2FF,stroke-width:2px,color:#0B1B45 style Data fill:#FFF2EB,stroke:#FB581F,stroke-width:2px,color:#0B1B45 style Ship fill:#FFF2EB,stroke:#FB581F,stroke-width:2px,color:#0B1B45 style You fill:#EEEEE7,stroke:#0B1B45,stroke-width:2px,color:#0B1B45 ``` The overall flow is: you **create your app** in the Partner Portal, **install it** in JTL Hub, **run it** in the ERP Cloud against **live data from JTL-Wawi**, and when everything looks good, **submit it** to the App Store. Now let's get everything set up. ## Step 1: Set up your Partner Portal This is your app management hub where you create apps and get your client credentials. Open [Partner Portal](https://partner.jtl-cloud.com/) in your browser. SSO is currently not supported with your JTL ID. You'll need to register separately with your email. Once you navigate to the URL, you'll be prompted to log in with your **JTL ID**. Click on the **Register now** button to create a new JTL ID. **JTL ID** provides a single access point to all JTL cloud-related services (JTL Hub, Partner Portal, ERP Cloud, etc.) Partner Portal registration screenshot On registering, you'll be asked to fill in the confirmation code sent to your email address. Once you fill in the code, you'll be redirected to the log in page. Once you log in with your credentials, choose one of the following: Create or link customer account * **New to JTL**: Click **Create organization** and fill in the required details if you don't have an existing Customer Center Account. * **Existing JTL customer**: Click **Link your JTL customer account** and log in to your Customer Center Account. Then **Authorize** JTL Cloud when prompted. **Customer Center Account** is your central hub for managing licenses, JTL services, billing, and support. After completing either option above, continue with the steps below: 1. Once your organization is created, you’ll see your organization name and custom URL path. Organization created 2. Click **Continue to JTL Hub** and you’ll be redirected to [JTL Hub dashboard](https://hub.jtl-cloud.com/). Continue to JTL Hub It’s recommended to set up 2FA before proceeding. 3. On your JTL Hub dashboard, click the [Partner Portal](https://partner.jtl-cloud.com/) link. Partner Portal link You’ll be redirected to the Partner Portal domain. Click **Create an account**. Next, select how you want to use the Partner Portal and then **Continue**. How to use Partner Portal 4. Enter your business contact details and **Create account**. Business contact details Once your account is created, you’ll be redirected to the Partner Portal dashboard. This is where you'll create and manage your apps. Partner Portal dashboard ## Step 2: Connect to JTL-Wawi The final step is to connect your JTL Cloud environment to JTL-Wawi. Your app uses this connection to read and write merchant data. Log in to your JTL-Wawi instance. In the **Admin** menu, click **JTL Cloud**, then click **Connect**. JTL Cloud > Connect option Read the message in the dialog carefully and click **OK**. This opens a new tab with the JTL Cloud login page. Log in using the JTL ID you created in [Step 1](#step-1-set-up-your-partner-portal). Once authentication is successful, close the browser tab and return to your JTL-Wawi instance. Back in JTL-Wawi, you'll see a **Connected** status, indicating the pairing was successful. Connected Wawi instance Pairing JTL-Wawi with JTL Cloud is not sufficient on its own. You must also configure and start the Wawi API so your app can access and sync data. Open the **JTL Administrator** to start the Wawi API. JTL creates a desktop shortcut for the JTL Administrator after installation.\ For older versions of JTL-Wawi, you can find it in the installation folder. Create a new entry by selecting **Wawi-API**. Create new API entry Select the desired profile and certificate, then enable the **Cloud Connection** checkbox. Click **Save**. Cloud Connection via JTL Administrator Once the connection is established, you will see an **is running** status. This may take a few seconds. Connected Wawi instance You can start or stop the API service from the JTL Administrator. If the API service is stopped, Cloud Apps cannot read or write data. Navigate to the **Connection (JTL-Wawi)** menu in JTL Hub to see your connected Wawi instance. Connected Wawi instance You now have everything you need to start building Cloud Apps. ## What's Next? Start from a template and get a Cloud App running in minutes. Recommended for most developers. Build a Cloud App step by step to understand how everything fits together. **Need help?** If you run into issues during account setup, reach out via the [support channel](/help/support). # C# Source: https://developer.jtl-software.com/get-started/quick-start/from-scratch/backend-csharp Build the ASP.NET Core backend for your JTL Cloud App. Verify session tokens, fetch access tokens, and proxy requests to the JTL Cloud API. Build the ASP.NET Core backend for a JTL Cloud App using .NET 8 and `NSec.Cryptography` 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:** .NET 8, ASP.NET Core Web API, `NSec.Cryptography` for JWT verification. ## Prerequisites You need: * ✅ **A finished frontend** from the [Build the Frontend](/get-started/quick-start/from-scratch/frontend) page, running locally on `http://localhost:5173` * ✅ **[.NET SDK](https://dotnet.microsoft.com/download) 8.0 or higher**. Run `dotnet --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 ```mermaid theme={null} sequenceDiagram participant FE as Your Frontend participant BE as Your Backend (ASP.NET Core) participant Auth as JTL Auth participant API as JTL Cloud API FE->>BE: GET /api/connect-tenant (X-Session-ID: token) BE->>Auth: POST /oauth2/token (client credentials) Auth-->>BE: Access token (JWT) BE->>API: GET /.well-known/jwks.json (with access token) API-->>BE: Public keys (JWKS) BE->>BE: Verify session token signature BE-->>FE: Verified tenant info ``` 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 scaffold a Web API project inside it. ```bash theme={null} cd my-jtl-app dotnet new webapi -n Backend ``` Your project structure now looks like this: ``` my-jtl-app/ ├── frontend/ # From the previous page └── Backend/ # New ``` The `dotnet new webapi` template includes a sample `WeatherForecast` controller and model. You can leave these in place or delete them. They won't affect anything you build in this guide. ## 2. Install Packages ```bash theme={null} cd Backend dotnet add package NSec.Cryptography ``` | Package | Purpose | | ------------------- | ------------------------------------------------------------------------------------------------ | | `NSec.Cryptography` | Cryptographic primitives for Ed25519 signature verification, used to validate JTL session tokens | ## 3. Set up Environment Variables ASP.NET Core uses `appsettings.json` for configuration. For local secrets, use the .NET user-secrets tool so credentials never end up in source control. From the `Backend/` directory: ```bash theme={null} dotnet user-secrets init dotnet user-secrets set "Jtl:ClientId" "your-client-id-here" dotnet user-secrets set "Jtl:ClientSecret" "your-client-secret-here" ``` The values are placeholders for now. You'll get real values from the [Partner Portal](https://partner.jtl-cloud.com/) in the next page and update them with the same `dotnet user-secrets set` commands. For production, swap user-secrets for environment variables, Azure Key Vault, or whichever secrets manager fits your hosting platform. ## 4. Build the Auth Service The first piece of the backend is a service 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/Services/JtlAuthService.cs`: ```csharp theme={null} using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace Backend.Services; public class JtlAuthService { private readonly HttpClient _http; private readonly IConfiguration _config; public JtlAuthService(HttpClient http, IConfiguration config) { _http = http; _config = config; } public string ApiBaseUrl => "https://api.jtl-cloud.com"; public string AuthEndpoint => "https://auth.jtl-cloud.com/oauth2/token"; public async Task GetJwtAsync() { var clientId = _config["Jtl:ClientId"]; var clientSecret = _config["Jtl:ClientSecret"]; if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) { throw new InvalidOperationException( "Jtl:ClientId and Jtl:ClientSecret must be configured."); } var basic = Convert.ToBase64String( Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); var request = new HttpRequestMessage(HttpMethod.Post, AuthEndpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); request.Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", }); var response = await _http.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new HttpRequestException( $"Failed to fetch JWT ({(int)response.StatusCode}): {body}"); } using var doc = JsonDocument.Parse(body); return doc.RootElement.GetProperty("access_token").GetString()!; } } ``` The service reads its credentials from `IConfiguration` (which user-secrets feeds into automatically), encodes them as Basic auth, and sends a `client_credentials` grant request to JTL's auth endpoint. The token is short-lived, so the method 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. JTL signs session tokens with `Ed25519`. Create `Backend/Services/SessionVerifier.cs`: ```csharp theme={null} using System.Net.Http.Headers; using System.Text; using System.Text.Json; using NSec.Cryptography; namespace Backend.Services; public record SessionTokenPayload( long Exp, string UserId, string TenantId, string? TenantSlug); public class SessionVerifier { private readonly HttpClient _http; private readonly JtlAuthService _auth; private readonly ILogger _logger; public SessionVerifier( HttpClient http, JtlAuthService auth, ILogger logger) { _http = http; _auth = auth; _logger = logger; } public async Task VerifyAsync(string sessionToken) { var jwt = await _auth.GetJwtAsync(); var request = new HttpRequestMessage( HttpMethod.Get, $"{_auth.ApiBaseUrl}/account/.well-known/jwks.json"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); var response = await _http.SendAsync(request); if (!response.IsSuccessStatusCode) { throw new HttpRequestException( $"Failed to fetch JWKS ({(int)response.StatusCode})"); } var jwksJson = await response.Content.ReadAsStringAsync(); var jwks = JsonSerializer.Deserialize(jwksJson); // Select the signing key by its JWK properties (use=sig, alg=EdDSA) JsonElement? signingKey = null; foreach (var k in jwks.GetProperty("keys").EnumerateArray()) { var use = k.TryGetProperty("use", out var u) ? u.GetString() : null; var alg = k.TryGetProperty("alg", out var a) ? a.GetString() : null; if (use == "sig" && alg == "EdDSA") { signingKey = k; break; } } if (signingKey is null) throw new InvalidOperationException("No EdDSA signing key found in JWKS"); // JTL session tokens are signed with Ed25519 (OKP) var publicKeyBytes = Base64UrlDecode(signingKey.Value.GetProperty("x").GetString()!); return VerifyAndDecode(sessionToken, publicKeyBytes); } private SessionTokenPayload VerifyAndDecode(string token, byte[] publicKeyBytes) { var parts = token.Split('.'); if (parts.Length != 3) { throw new ArgumentException( "Invalid JWT format. Expected 3 dot-separated parts."); } var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); var signature = Base64UrlDecode(parts[2]); var algorithm = SignatureAlgorithm.Ed25519; var publicKey = PublicKey.Import( algorithm, publicKeyBytes, KeyBlobFormat.RawPublicKey); if (!algorithm.Verify(publicKey, signedData, signature)) { _logger.LogError("JWT signature verification failed"); throw new UnauthorizedAccessException("Invalid token signature."); } var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1])); var payload = JsonSerializer.Deserialize(payloadJson); var exp = payload.GetProperty("exp").GetInt64(); if (DateTimeOffset.FromUnixTimeSeconds(exp) < DateTimeOffset.UtcNow) { throw new UnauthorizedAccessException("Token has expired."); } return new SessionTokenPayload( Exp: exp, UserId: payload.GetProperty("userId").GetString()!, TenantId: payload.GetProperty("tenantId").GetString()!, TenantSlug: payload.TryGetProperty("tenantSlug", out var slug) ? slug.GetString() : null); } private static byte[] Base64UrlDecode(string input) { var padded = input.Replace('-', '+').Replace('_', '/'); padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; return Convert.FromBase64String(padded); } } ``` There are two things going on inside this service. The public `VerifyAsync` method fetches the JWKS document and pulls out the first public key. The private `VerifyAndDecode` method splits the JWT into its three parts (header, payload, signature), reconstructs the signed data, and asks `NSec` to verify the signature against the public key. If the signature is valid and the token isn't expired, it returns the decoded payload as a strongly-typed record. ## 6. Build the Connect Tenant Controller Now wire the verifier into a controller. The frontend's shell layout sends the session token to `/api/connect-tenant` and expects a tenant ID back. Create `Backend/Controllers/ConnectTenantController.cs`: ```csharp theme={null} using Backend.Services; using Microsoft.AspNetCore.Mvc; namespace Backend.Controllers; [ApiController] [Route("api/connect-tenant")] public class ConnectTenantController : ControllerBase { private readonly SessionVerifier _verifier; private readonly ILogger _logger; public ConnectTenantController( SessionVerifier verifier, ILogger logger) { _verifier = verifier; _logger = logger; } [HttpGet] public async Task Get() { var sessionToken = Request.Headers["X-Session-ID"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(sessionToken)) { return BadRequest(new { error = "Session token is required" }); } try { var payload = await _verifier.VerifyAsync(sessionToken); // In a production app, you'd store the tenant connection in your database here _logger.LogInformation("Tenant connected: {TenantId}", payload.TenantId); return Ok(new { success = true, tenantId = payload.TenantId, userId = payload.UserId, tenantSlug = payload.TenantSlug, }); } catch (Exception ex) { _logger.LogError(ex, "Connection failed"); return Unauthorized(new { error = "Failed to verify session token" }); } } } ``` The controller reads the session token from the `X-Session-ID` request header, hands it to the verifier, and returns the tenant details on success or a 401 on failure. The `[ApiController]` attribute handles model binding and validation automatically. See the [Tenant Mapping](/guides/cloud-apps/authentication-login#tenant-mapping) section for more on managing tenants in production. ## 7. Wire up Services and CORS ASP.NET Core needs to know about the services it should inject and the controllers it should expose. Replace the contents of `Backend/Program.cs`: ```csharp theme={null} using Backend.Services; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); // Allow the Vite dev server to call the API during development builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { policy.WithOrigins("http://localhost:5173") .AllowAnyHeader() .AllowAnyMethod(); }); }); var app = builder.Build(); app.UseCors(); app.MapControllers(); app.Run(); ``` `AddHttpClient` does two things at once: it registers the service in the DI container and gives each instance its own `HttpClient` from the built-in factory. The CORS policy 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 middleware is a useful safety net during development. ## 8. Configure the Backend Port By default, `dotnet new webapi` listens on a random port chosen at startup, but the frontend's Vite dev proxy expects the backend on `http://localhost:5273`. Pin the port in `Backend/Properties/launchSettings.json` by replacing the `applicationUrl` value in the `http` profile: ```json theme={null} { "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, "applicationUrl": "http://localhost:5273", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ``` You can leave the other profiles (`https`, `IIS Express`) in place. The `dotnet run` command picks the `http` profile by default during development. ## 9. Run the Backend Start the backend: ```bash theme={null} dotnet run ``` You should see output like: ``` Now listening on: http://localhost:5273 Application started. Press Ctrl+C to shut down. ``` In a second terminal, send a request with a fake session token to confirm the route is reachable: ```bash theme={null} 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"}`. A real session token from the App Shell will follow the same path and succeed. ## Common Issues This error means the backend started but couldn't find your credentials in configuration. The most common cause is that user-secrets weren't initialised, or were set in a different directory than the project expects. User-secrets are scoped to a specific project, identified by the `UserSecretsId` GUID in the `.csproj` file. Running `dotnet user-secrets list` from inside the `Backend/` directory will show whether the secrets are present for this project. If the list comes back empty, running `dotnet user-secrets init` followed by the two `dotnet user-secrets set` commands from earlier in the guide will populate them. It's also worth confirming that you ran the user-secrets commands from the `Backend/` directory and not the project root. The tool resolves the target project based on the current working directory. A 401 from JTL's auth endpoint means the credentials being sent aren't recognised. Until you've registered your app in the Partner Portal, this is the expected response, since the placeholder values from earlier aren't real credentials. The next page in this guide walks through registration and provides the real values. If you've already registered the app and are still seeing this error, the most likely causes are typos in the user-secrets values and forgetting to restart the backend after updating them. ASP.NET Core reads configuration once at startup, so changes to user-secrets take effect only on the next `dotnet run`. The `dotnet user-secrets list` command is the fastest way to confirm the stored values match what the Partner Portal showed you. 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 policy in `Program.cs` 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 `WithOrigins` value in `Program.cs` to match the actual Vite port, or freeing up port 5173, should resolve it. Worth noting: when the Vite dev proxy is forwarding `/api/*` requests, the browser shouldn't actually see CORS at all, since the requests look same-origin from the browser's perspective. CORS errors usually appear when something is calling the backend directly on `localhost:5273` instead of going through the proxy. If `dotnet run` reports a different port (for example, 5000 or a random high number), the `launchSettings.json` change from earlier in the guide either hasn't been saved or isn't being picked up. Confirming that `Backend/Properties/launchSettings.json` contains `"applicationUrl": "http://localhost:5273"` in the `http` profile, then stopping and restarting `dotnet run`, should pin the port. If `dotnet run` is using a different profile than expected, passing `--launch-profile http` explicitly will force it to use the right one. An alternative if you'd rather not edit `launchSettings.json` is to set the `ASPNETCORE_URLS` environment variable: `ASPNETCORE_URLS=http://localhost:5273 dotnet run`. This works without any config file changes. ## 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: Register your app, install it in the JTL Hub, and fetch products from JTL-Wawi. # Node.js Source: https://developer.jtl-software.com/get-started/quick-start/from-scratch/backend-node Build the Express backend for your JTL Cloud App. Verify session tokens, fetch access tokens, and proxy requests to the JTL Cloud API. Build the Express backend for a JTL Cloud App using Node.js, TypeScript, and the `jose` library for JWT verification. By the end, the backend runs locally and the frontend's "Waiting for backend" placeholder turns into a real connection. **Stack:** Node.js 24.16.0+, Express, TypeScript, `jose` for JWT verification. ## Prerequisites You need: * ✅ **A finished frontend** from the [Build the Frontend](/get-started/quick-start/from-scratch/frontend) page, running locally on `http://localhost:5173` * ✅ **[Node.js](https://nodejs.org/) v24.16.0 or higher** (current LTS, includes `npm`). Verify with `node --version` and `npm --version` ## 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 ```mermaid theme={null} sequenceDiagram participant FE as Your Frontend participant BE as Your Backend (Express) participant Auth as JTL Auth participant API as JTL Cloud API FE->>BE: POST /api/connect-tenant (session token) BE->>Auth: POST /oauth2/token (client credentials) Auth-->>BE: Access token (JWT) BE->>API: GET /.well-known/jwks.json (with access token) API-->>BE: Public keys (JWKS) BE->>BE: Verify session token signature BE-->>FE: Verified tenant info ``` 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 initialize it. ```bash theme={null} cd my-jtl-app mkdir backend cd backend npm init -y ``` Your project structure now looks like this: ``` my-jtl-app/ ├── frontend/ # From the previous page └── backend/ # New ``` ## 2. Install Packages ```bash theme={null} npm install express cors jose npm install -D typescript tsx @types/node @types/express @types/cors ``` | Package | Purpose | | ------------ | ---------------------------------------------------------------------- | | `express` | HTTP server and routing | | `cors` | Allows the frontend dev server to call the backend during development | | `jose` | Verifies JWTs using JTL's public keys | | `typescript` | Type checking and `tsc` compiler | | `tsx` | Runs TypeScript files directly during development without a build step | | `@types/*` | Type definitions for the runtime packages | ## 3. Configure TypeScript Create `backend/tsconfig.json`: ```json theme={null} { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "strict": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src" }, "include": ["src/**/*"] } ``` This configuration uses Node's native ESM support (`NodeNext`) with strict type checking. The `outDir` and `rootDir` settings keep compiled output separate from source files when you eventually build for production. ## 4. Set up Environment Variables The backend needs two secrets: a client ID and a client secret, both issued by JTL when you create your app. You will get the real values from the [Partner Portal](https://partner.jtl-cloud.com) in the next page. For now, create the file with placeholder values so the rest of the setup works. Create `backend/.env`: ```dotenv theme={null} 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` to `backend/.gitignore` so the secrets don't end up in version control: ``` node_modules dist .env ``` ## 5. 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/jtl-auth.ts`: ```typescript theme={null} const AUTH_ENDPOINT = 'https://auth.jtl-cloud.com/oauth2/token'; const API_BASE_URL = 'https://api.jtl-cloud.com'; export function getApiBaseUrl(): string { return API_BASE_URL; } export async function getJwt(): Promise { 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 .env'); } const authString = Buffer.from(`${clientId}:${clientSecret}`).toString( 'base64', ); const response = await fetch(AUTH_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${authString}`, }, body: new URLSearchParams({ grant_type: 'client_credentials' }), }); const data = await response.json(); if (!response.ok) { throw new Error( `Failed to fetch JWT (${response.status}): ${data.error}`, ); } 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. ## 6. Build the Session Verifier With an access token in hand, the backend can fetch the public keys it needs to verify session tokens. The `jose` library handles the cryptographic work once it has the public key in the right format. Create `backend/src/verify-session.ts`: ```typescript theme={null} import { importJWK, jwtVerify } from 'jose'; import { getJwt, getApiBaseUrl } from './jtl-auth.js'; export interface SessionTokenPayload { exp: number; userId: string; tenantId: string; tenantSlug: string; } export async function verifySessionToken( sessionToken: string, ): Promise { const accessToken = await getJwt(); const baseUrl = getApiBaseUrl(); const response = await fetch(`${baseUrl}/account/.well-known/jwks.json`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { throw new Error(`Failed to fetch JWKS (${response.status})`); } const jwks = await response.json(); // Select the signing key by its JWK properties (use=sig, alg=EdDSA) const signingKey = jwks.keys.find( (k: { use?: string; alg?: string }) => k.use === 'sig' && k.alg === 'EdDSA', ); if (!signingKey) { throw new Error('No EdDSA signing key found in JWKS'); } const publicKey = await importJWK(signingKey, 'EdDSA'); const { payload } = await jwtVerify(sessionToken, publicKey); return payload as unknown as SessionTokenPayload; } ``` The function fetches the JWKS document from the JTL API, imports the first key as an EdDSA public key, and asks `jose` to verify the session token's signature against it. If the signature is valid and the token isn't expired, `jwtVerify` returns the decoded payload. If anything is wrong, it throws an error. ## 7. Build the Connect Tenant Endpoint Now connect the verifier into an Express route. The frontend's shell layout sends the session token to `/api/connect-tenant` and expects a tenant ID back. Create `backend/src/server.ts`: ```typescript theme={null} import express, { type Request, type Response } from 'express'; import cors from 'cors'; import { verifySessionToken } from './verify-session.js'; const app = express(); const port = Number(process.env.PORT) || 5273; app.use(cors({ origin: 'http://localhost:5173' })); app.use(express.json()); app.get('/api/connect-tenant', async (req: Request, res: Response) => { const sessionToken = req.headers['x-session-id'] as string; if (!sessionToken) { return res.status(400).json({ error: 'Session token is required' }); } try { const payload = await verifySessionToken(sessionToken); // In a production app, you'd store the tenant connection in your database here console.log('Tenant connected:', payload.tenantId); return res.json({ success: true, tenantId: payload.tenantId, userId: payload.userId, tenantSlug: payload.tenantSlug, }); } catch (error) { console.error('Connection failed:', error); return res .status(401) .json({ error: 'Failed to verify session token' }); } }); app.listen(port, () => { console.log(`Backend listening on http://localhost:${port}`); }); ``` CORS is configured to allow 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 middleware is a useful safety net during development and stays out of the way in production. See the [Tenant Mapping](/guides/cloud-apps/authentication-login#tenant-mapping) section for more on managing tenants in production. ## 8. Add Run Scripts Open `backend/package.json` and replace the `scripts` block: ```json theme={null} "scripts": { "dev": "tsx watch --env-file=.env src/server.ts", "build": "tsc", "start": "node --env-file=.env dist/server.js" } ``` The `dev` script uses `tsx watch` to run TypeScript directly, restarting the server when any source file changes. The `--env-file=.env` flag loads environment variables natively without needing `dotenv`. The `build` and `start` scripts compile to JavaScript and run the compiled output for production. Also update the `"type": "commonjs"` to `"type": "module"` below the `scripts` in the `package.json` file so that Node can treat `.js` files as ES modules. ## 9. Run the Backend Start the dev server: ```bash theme={null} npm run dev ``` You should see: ``` Backend listening on http://localhost:5273 ``` In a second terminal, send a request with a fake session token to confirm the route is reachable: ```bash theme={null} 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"}`. A real session token from the App Shell will follow the same path and succeed. ## Common Issues This error usually means TypeScript and Node are resolving modules differently. With `"type": "module"` in `package.json` and `"module": "NodeNext"` in `tsconfig.json`, Node expects ES module imports to include file extensions. Even if your source file is `jtl-auth.ts`, the import must use `./jtl-auth.js`. TypeScript resolves this correctly during development, and Node finds the compiled `.js` file at runtime. If you prefer not to use `.js` extensions, switch to `"module": "CommonJS"` in `tsconfig.json` and remove `"type": "module"` from `package.json`. This means the backend started without loading your environment variables. The most common cause is running Node without the `--env-file=.env` flag. In that case, the `.env` file exists but is never read. The `dev` and `start` scripts already include this flag. If you're running the server manually, add it back or use `npm run dev`. Also confirm that the `.env` file is inside the `backend/` directory. The path is resolved relative to where Node is executed. 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. This means the browser blocked the response due to an origin mismatch. The backend allows requests from `http://localhost:5173`, which is the default Vite dev server port. If Vite runs on a different port (for example, 5174), the request will be rejected. Update the `origin` in `server.ts` to match the actual port, or restart Vite on 5173. If you're using the Vite dev proxy for `/api/*`, CORS should not appear. Seeing this error usually means the request is being made directly to the backend instead of going through the proxy. ## 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: Register your app, install it in the JTL Hub, and fetch products from JTL-Wawi. # PHP Source: https://developer.jtl-software.com/get-started/quick-start/from-scratch/backend-php Build the Slim backend for your JTL Cloud App. Verify session tokens, fetch access tokens, and proxy requests to the JTL Cloud API. 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, Guzzle for HTTP requests, `vlucas/phpdotenv` for environment variables, native `ext-sodium` for JWT verification. ## Prerequisites You need: * ✅ **A finished frontend** from the [Build the Frontend](/get-started/quick-start/from-scratch/frontend) page, running locally on `http://localhost:5173` * ✅ **[PHP](https://www.php.net/downloads) 8.1 or higher** with `ext-sodium` enabled. Run `php --version` and `php -m | grep sodium` to check. * ✅ **[Composer](https://getcomposer.org/)** 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 ```mermaid theme={null} sequenceDiagram participant FE as Your Frontend participant BE as Your Backend (Slim) participant Auth as JTL Auth participant API as JTL Cloud API FE->>BE: GET /api/connect-tenant (X-Session-ID: token) BE->>Auth: POST /oauth2/token (client credentials) Auth-->>BE: Access token (JWT) BE->>API: GET /.well-known/jwks.json (with access token) API-->>BE: Public keys (JWKS) BE->>BE: Verify session token signature BE-->>FE: Verified tenant info ``` 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. ```bash theme={null} 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 ```bash theme={null} composer require slim/slim slim/psr7 guzzlehttp/guzzle vlucas/phpdotenv ``` | Package | Purpose | | ------------------- | ------------------------------------------------------------------ | | `slim/slim` | The Slim 4 micro-framework: routing, request and response handling | | `slim/psr7` | The PSR-7 implementation Slim uses by default | | `guzzlehttp/guzzle` | HTTP client for making requests to JTL APIs | | `vlucas/phpdotenv` | Loads environment variables from a `.env` file | ## 3. Set up Environment Variables Create `backend/.env`: ```dotenv theme={null} 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 theme={null} cachedToken !== null && time() < $this->tokenExpiresAt) { return $this->cachedToken; } if (empty($this->clientId) || empty($this->clientSecret)) { throw new \RuntimeException('CLIENT_ID and CLIENT_SECRET must be defined in .env'); } $authString = base64_encode("{$this->clientId}:{$this->clientSecret}"); $response = $this->httpClient->request('POST', self::AUTH_ENDPOINT, [ 'http_errors' => false, 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json', 'Authorization' => "Basic {$authString}", ], 'form_params' => ['grant_type' => 'client_credentials'], ]); $statusCode = $response->getStatusCode(); $body = $response->getBody()->getContents(); if ($statusCode < 200 || $statusCode >= 300) { throw new \RuntimeException("Failed to fetch JWT ({$statusCode}): {$body}"); } $data = json_decode($body, true); $expiresIn = $data['expires_in'] ?? 3600; $this->cachedToken = $data['access_token']; $this->tokenExpiresAt = time() + $expiresIn - 30; return $this->cachedToken; } } ``` The credentials are Base64-encoded and sent as a `client_credentials` grant to JTL's auth endpoint. The access token is cached in memory until 30 seconds before expiry, so repeat calls within the same request reuse it. For cross-request reuse, swap the in-memory cache for APCu or another shared cache. ## 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 theme={null} auth->getJwt(); $response = $this->httpClient->request('GET', JtlAuth::API_BASE_URL . '/account/.well-known/jwks.json', [ 'http_errors' => false, 'headers' => [ 'Authorization' => "Bearer {$accessToken}", 'Accept' => 'application/json', ], ]); $statusCode = $response->getStatusCode(); $body = $response->getBody()->getContents(); if ($statusCode < 200 || $statusCode >= 300) { throw new \RuntimeException("Failed to fetch JWKS ({$statusCode})"); } $jwks = json_decode($body, true); // Select the signing key by its JWK properties (use=sig, alg=EdDSA) $signingKey = null; foreach ($jwks['keys'] as $k) { if (($k['use'] ?? null) === 'sig' && ($k['alg'] ?? null) === 'EdDSA') { $signingKey = $k; break; } } if ($signingKey === null) { throw new \RuntimeException('No EdDSA signing key found in JWKS'); } $publicKey = $this->base64UrlDecode($signingKey['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.'); } return [ 'exp' => $decodedPayload['exp'], 'userId' => $decodedPayload['userId'], 'tenantId' => $decodedPayload['tenantId'], 'tenantSlug' => $decodedPayload['tenantSlug'] ?? null, ]; } 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, selects the EdDSA signing 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. ## 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 theme={null} load(); $httpClient = new Client(); $auth = new JtlAuth($_ENV['CLIENT_ID'] ?? '', $_ENV['CLIENT_SECRET'] ?? '', $httpClient); $verifier = new SessionVerifier($auth, $httpClient); $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](/guides/cloud-apps/authentication-login#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: ```json theme={null} { "name": "my-jtl-app/backend", "type": "project", "require": { "slim/slim": "^4.15", "slim/psr7": "^1.7", "guzzlehttp/guzzle": "^7.9", "vlucas/phpdotenv": "^5.6" }, "autoload": { "psr-4": { "App\\": "src/" } } } ``` Then regenerate the autoloader so Composer picks up the new mapping: ```bash theme={null} 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: ```bash theme={null} 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: ```bash theme={null} 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: Register your app, install it in the JTL Hub, and fetch products from JTL-Wawi. # Connect and Fetch Data Source: https://developer.jtl-software.com/get-started/quick-start/from-scratch/connect-and-fetch Register your app with JTL, install it in the JTL Hub, and fetch real product data from JTL-Wawi. Bring everything together. Register your app with JTL to get real credentials, install it in the JTL Hub, then add an items endpoint to your backend and a product table to your frontend. ## Prerequisites You need: * ✅ **A finished frontend** from the [Build the Frontend](/get-started/quick-start/from-scratch/frontend) guide * ✅ **A finished backend** from either the [Node.js](/get-started/quick-start/from-scratch/backend-node), [C#](/get-started/quick-start/from-scratch/backend-csharp), or [PHP](/get-started/quick-start/from-scratch/backend-php) guide * ✅ **A JTL ID** (your login to the JTL ecosystem) * ✅ **Access to an organization (tenant) in the [Partner Portal](https://partner.jtl-cloud.com)** (created automatically on first login if you don’t have one yet) * ✅ **[JTL-Wawi](https://www.jtl-software.com/de/warenwirtschaftssystem-software/jtl-wawi-download)** installed and running locally, with at least one product, and connected to JTL Cloud. If you don’t have JTL Cloud set up yet, follow the step-by-step guide: [Create a Developer Account](/get-started/create-developer-account). ## What you're Building So far, the frontend and backend are only loosely connected. The frontend can render the app, and the backend can verify authentication tokens, but neither side is yet tied to a real merchant installation. This guide closes the loop. ```mermaid theme={null} sequenceDiagram participant Dev as You participant Portal as Partner Portal participant Hub as JTL Hub participant App as Your App participant Wawi as JTL-Wawi Dev->>Portal: Register app with manifest Portal-->>Dev: Client ID + Client Secret Dev->>Hub: Install app in test tenant Hub->>App: Loads /setup in iframe App->>App: Verifies session, completes handshake Dev->>App: Opens /erp menu item App->>Wawi: GET /api/items (with session token) Wawi-->>App: Product data ``` By the end, opening the app from the ERP Cloud menu will show a table of real products from JTL-Wawi. ## 1. Create the Manifest The manifest tells JTL three things: how your app integrates technically, how it appears in the App Store, and where to send merchants when they install or open it. Create `manifest.json` in the project root: ```json theme={null} { "manifest": { "version": "1.0.0", "technicalName": "my-jtl-app", "lifecycle": { "configurationUrl": "http://localhost:5173/setup" }, "capabilities": { "hub": { "appLauncher": { "redirectUrl": "http://localhost:5173/hub" } }, "erp": { "menuItems": [ { "id": "my-app-menu", "name": "My JTL App", "url": "http://localhost:5173/erp" } ], "api": { "scopes": [] } } } }, "listing": { "version": "1.0.0", "defaultLocale": "en", "name": { "en": { "short": "my-jtl-app", "full": "My JTL Cloud App" } }, "description": { "en": { "short": "A Cloud App built from scratch", "full": "A sample Cloud App built from scratch using Vite, React, and TypeScript." } }, "media": { "icons": { "light": "https://hub.jtl-cloud.com/assets/image-placeholder.png", "dark": "https://hub.jtl-cloud.com/assets/image-placeholder.png" } }, "support": { "url": { "en": "https://support.example.com/help" } }, "legal": { "privacyPolicy": "https://example.com/privacy", "termsOfUse": "https://example.com/terms", "gdpr": { "request": "https://example.com/gdpr/request", "delete": "https://example.com/gdpr/delete" } } } } ``` The three URLs under `capabilities` map directly to the three frontend routes: | Manifest field | Page in your code | | --------------------------------------------------- | ------------------ | | `manifest.lifecycle.configurationUrl` | `_shell.setup.tsx` | | `manifest.capabilities.hub.appLauncher.redirectUrl` | `hub.tsx` | | `manifest.capabilities.erp.menuItems[].url` | `_shell.erp.tsx` | The example uses `http://localhost:5173/...` for the manifest URLs and `https://example.com/...` for the listing URLs. When you deploy, replace both with your real production domain. All URLs must be publicly reachable. `localhost` works only because the JTL App Shell runs in your browser, not on JTL's servers. ## 2. Register your App Go to [Partner Portal](https://partner.jtl-cloud.com/) and log in. Click **+ Create**. You'll see a manifest editor with a pre-filled example. Replace the example manifest with the contents of your `manifest.json` file. Click **Register app**. Partner Portal app creation screenshot After registration, you'll see your **Client ID** and **Client Secret**. The Client Secret is shown only once. Copy the value immediately. If you lose it, you'll need to register a new app. ## 3. Update your Backend Credentials Replace the placeholder values you set earlier with the real Client ID and Client Secret. Open `backend/.env` and replace the placeholder values: ```dotenv theme={null} CLIENT_ID=your-actual-client-id CLIENT_SECRET=your-actual-client-secret PORT=5273 ``` The `tsx watch` command picks up source file changes automatically, but environment variables are read once at startup. Stop and restart the backend to pick up the new values. From the `Backend/` directory, update the user-secrets values: ```bash theme={null} dotnet user-secrets set "Jtl:ClientId" "your-actual-client-id" dotnet user-secrets set "Jtl:ClientSecret" "your-actual-client-secret" ``` ASP.NET Core reads configuration once at startup. Stop and restart the backend to pick up the new values. Open `backend/.env` and replace the placeholder values: ```dotenv theme={null} CLIENT_ID=your-actual-client-id CLIENT_SECRET=your-actual-client-secret PORT=5273 ``` PHP's built-in development server reads environment variables once at startup. Stop the server with `Ctrl+C` and start it again to pick up the new values: ## 4. Run Both Processes You need both the frontend and backend running. Open two terminals from the project root. **Terminal 1 (backend):** ```bash theme={null} cd backend npm run dev ``` ```bash theme={null} cd Backend dotnet run ``` ```bash theme={null} cd backend php -S localhost:5273 -t public ``` **Terminal 2 (frontend):** ```bash theme={null} cd frontend npm run dev ``` Opening `http://localhost:5173` directly in a browser will show a blank page or the placeholder error state. The app is meant to be rendered inside the JTL App Shell, which is what the next step covers. ## 5. Install the App in JTL Hub Go to [JTL Hub](https://hub.jtl-cloud.com/) and log in. Navigate to the **Manage apps** menu and click the **Apps in development** tab. You should see your newly registered app. Discover apps Once the app is installed, you'll see a success screen with the option to configure the app. Click the **Configure app** button to proceed to the next step. Install app on JTL Click the **Complete Setup** button on your setup page. This triggers the full setup handshake between your app and JTL. Installation complete The setup page now displays a valid tenant ID. This confirms that the session token was successfully verified through your backend and that the App Shell recognizes the installation as complete. ## 6. Add an Items Endpoint The backend can now make tenant-scoped calls to the JTL Cloud API. The next step is adding an endpoint that fetches products from JTL-Wawi via GraphQL. Every API request that needs to access tenant data needs an `X-Tenant-ID` header. The backend extracts this value from the verified session token payload. See the [Session Token Payload](/guides/essentials/authentication/api-keys-tokens#payload) reference for details. Add the items route to `backend/src/server.ts`. Place it alongside the existing `connect-tenant` route: ```typescript theme={null} import { getJwt } from './jtl-auth.js'; app.get('/api/items', async (req: Request, res: Response) => { const authHeader = req.header('authorization'); if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing or invalid Authorization header' }); } const sessionToken = authHeader.slice(7); try { const payload = await verifySessionToken(sessionToken); const accessToken = await getJwt(); 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': payload.tenantId, }, body: JSON.stringify({ operationName: 'GetERPItems', query: ` query GetERPItems { QueryItems(first: 10) { nodes { id sku name notes basePriceUnit } totalCount } } `, }), }); const body = await response.json(); return res.json(body); } catch (error) { console.error('Failed to fetch items:', error); return res.status(500).json({ error: 'Failed to fetch items' }); } }); ``` The route reads the session token from the `Authorization` header (sent by the frontend as a `Bearer` token), verifies it to extract the tenant ID, fetches a fresh access token, and forwards the GraphQL response back to the frontend as-is. Create `Backend/Controllers/ItemsController.cs`: ```csharp theme={null} using System.Net.Http.Headers; using System.Text; using Backend.Services; using Microsoft.AspNetCore.Mvc; namespace Backend.Controllers; [ApiController] [Route("api/items")] public class ItemsController : ControllerBase { private readonly HttpClient _http; private readonly JtlAuthService _auth; private readonly SessionVerifier _verifier; public ItemsController( IHttpClientFactory httpFactory, JtlAuthService auth, SessionVerifier verifier) { _http = httpFactory.CreateClient(); _auth = auth; _verifier = verifier; } [HttpGet] public async Task Get() { var authHeader = Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) { return Unauthorized(new { error = "Missing or invalid Authorization header" }); } var sessionToken = authHeader.Substring("Bearer ".Length); try { var payload = await _verifier.VerifyAsync(sessionToken); var accessToken = await _auth.GetJwtAsync(); var query = """ { "operationName": "GetERPItems", "query": "query GetERPItems { QueryItems(first: 10) { nodes { id sku name notes basePriceUnit } totalCount } }" } """; var request = new HttpRequestMessage( HttpMethod.Post, $"{_auth.ApiBaseUrl}/erp/v2/graphql"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); request.Headers.Add("X-Tenant-ID", payload.TenantId); request.Content = new StringContent(query, Encoding.UTF8, "application/json"); var response = await _http.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); return Content(body, "application/json"); } catch (Exception ex) { return StatusCode(500, new { error = "Failed to fetch items", details = ex.Message }); } } } ``` The controller reads the session token from the `Authorization` header (sent by the frontend as a `Bearer` token), verifies it to extract the tenant ID, fetches a fresh access token, and forwards the GraphQL response back to the frontend as-is. The existing `app.MapControllers()` call in `Program.cs` already discovers this controller, so no extra registration is needed. Add the items route to `backend/public/index.php`. Place it alongside the existing `connect-tenant` route: ```php theme={null} $app->get('/api/items', function (Request $request, Response $response) use ($auth, $verifier, $httpClient) { $authHeader = $request->getHeaderLine('Authorization'); if (!str_starts_with($authHeader, 'Bearer ')) { $response->getBody()->write(json_encode(['error' => 'Missing or invalid Authorization header'])); return $response->withStatus(401)->withHeader('Content-Type', 'application/json'); } $sessionToken = substr($authHeader, 7); try { $payload = $verifier->verify($sessionToken); $accessToken = $auth->getJwt(); $apiResponse = $httpClient->request('POST', JtlAuth::API_BASE_URL . '/erp/v2/graphql', [ 'http_errors' => false, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => "Bearer {$accessToken}", 'X-Tenant-ID' => $payload['tenantId'], ], 'json' => [ 'operationName' => 'GetERPItems', 'query' => 'query GetERPItems { QueryItems(first: 10) { nodes { id sku name notes basePriceUnit } totalCount } }', ], ]); $body = $apiResponse->getBody()->getContents(); $response->getBody()->write($body); return $response->withHeader('Content-Type', 'application/json'); } catch (\Throwable $e) { error_log("Failed to fetch items: {$e->getMessage()}"); $response->getBody()->write(json_encode(['error' => 'Failed to fetch items'])); return $response->withStatus(500)->withHeader('Content-Type', 'application/json'); } }); ``` The route reads the session token from the `Authorization` header (sent by the frontend as a `Bearer` token), verifies it to extract the tenant ID, fetches a fresh access token, and forwards the GraphQL response back to the frontend as-is. ## 7. Display Items in the ERP Page Replace `frontend/src/routes/_shell.erp.tsx` with a version that fetches and renders the products: ```tsx theme={null} import { createFileRoute } from '@tanstack/react-router'; import { Box, Text } from '@jtl-software/platform-ui-react'; import { useCallback, useEffect, useState } from 'react'; import { useAppBridge } from '../appBridge'; interface Item { id: string; sku: string; name: string; } interface ItemsResponse { data: { QueryItems: { nodes: Item[]; totalCount: number; }; }; } function ErpPage() { const { appBridge, tenantId } = useAppBridge(); const [items, setItems] = useState([]); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fetchItems = useCallback(async () => { if (!appBridge) return; try { setLoading(true); const sessionToken = await appBridge.method.call('getSessionToken'); const res = await fetch('/api/items', { headers: { Authorization: `Bearer ${sessionToken}` }, }); if (!res.ok) { throw new Error(`Request failed (${res.status})`); } const data: ItemsResponse = await res.json(); setItems(data.data.QueryItems.nodes || []); setTotalCount(data.data.QueryItems.totalCount || 0); } catch (err) { console.error(String(err)); setError( err instanceof Error ? err.message : 'Failed to load products', ); } finally { setLoading(false); } }, [appBridge]); useEffect(() => { fetchItems(); }, [fetchItems]); if (error) { return ( Error {error} ); } return ( My JTL Cloud App Connected to tenant:{' '} {tenantId} Products from Cloud-ERP {loading ? ( Loading products... ) : items.length === 0 ? ( No products found. Make sure your test account has items in Cloud-ERP. ) : ( <> Showing {items.length} of {totalCount} products {items.map((item) => ( ))}
sku name
{item.sku} {item.name}
)}
); } export const Route = createFileRoute('/_shell/erp')({ component: ErpPage, }); ``` On mount, the component requests a session token from the AppBridge, sends it to your backend as the `Authorization` header, and renders the returned products in a table. The session token round-trip means the backend always knows which tenant the request belongs to, even though the frontend only ever holds a short-lived signed token. A sample response from the GraphQL API looks like this: ```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 } } } ``` ## 8. Open the App from the ERP Menu In the [ERP Cloud](https://erp.jtl-cloud.com), navigate to the **App** menu and find the **My JTL App** menu item that the manifest registered. Clicking it loads `/erp` inside the App Shell. You should see a header reading **My JTL Cloud App**, the connected tenant ID, and a table listing the first ten products from your JTL-Wawi instance. This completes the full handshake flow: the App Shell loads your frontend in an iframe, AppBridge provides a short-lived session token, your backend verifies the token and uses its access token to make a tenant-scoped request to the JTL Cloud API, and the response is returned to the browser. ## Common Issues This error usually means TypeScript and Node are resolving modules differently. With `"type": "module"` in `package.json` and `"module": "NodeNext"` in `tsconfig.json`, Node expects ES module imports to include file extensions. Even if your source file is `jtl-auth.ts`, the import must use `./jtl-auth.js`. TypeScript resolves this correctly during development, and Node finds the compiled `.js` file at runtime. If you prefer not to use `.js` extensions, switch to `"module": "CommonJS"` in `tsconfig.json` and remove `"type": "module"` from `package.json`. This means the backend started without loading your environment variables. The most common cause is running Node without the `--env-file=.env` flag. In that case, the `.env` file exists but is never read. The `dev` and `start` scripts already include this flag. If you're running the server manually, add it back or use `npm run dev`. Also confirm that the `.env` file is inside the `backend/` directory. The path is resolved relative to where Node is executed. 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. This usually means the backend responded, but not with a successful (2xx) response. The frontend treats any non-2xx response as an error and shows the placeholder state. Open the browser console and inspect the `/api/connect-tenant` response: * a 401 is expected at this stage without real credentials * a 500 or network error indicates a backend issue (check server logs) Once valid credentials are configured, this state resolves automatically. This means the browser blocked the response due to an origin mismatch. The backend allows requests from `http://localhost:5173`, which is the default Vite dev server port. If Vite runs on a different port (for example, 5174), the request will be rejected. Update the `origin` in `server.ts` to match the actual port, or restart Vite on 5173. If you're using the Vite dev proxy for `/api/*`, CORS should not appear. Seeing this error usually means the request is being made directly to the backend instead of going through the proxy. ## What's Next? You've built a working JTL Cloud App from scratch: a frontend that runs inside the JTL App Shell, a backend that verifies session tokens and proxies requests to the JTL Cloud API, and a real connection to a tenant pulling live product data. Where to go from here: Validate your app in the sandbox with test data. Use the JTL-Wawi REST and GraphQL APIs, handle responses, and work with tenant-scoped data. Try queries and mutations interactively against your ERP instance. Learn how to integrate deeper with the JTL UI and App Shell. Deploy your app and publish it for merchants. # Build the Frontend Source: https://developer.jtl-software.com/get-started/quick-start/from-scratch/frontend Scaffold the React frontend for your JTL Cloud App with TanStack Router and the JTL AppBridge SDK. Build the React frontend for a JTL Cloud App using Vite, TypeScript, [TanStack Router](https://tanstack.com/router/latest), and the JTL AppBridge SDK. By the end, the app runs locally with three working routes and a clear state for connecting to your backend. **Stack:** Vite, React, TypeScript, TanStack Router (file-based routing), JTL AppBridge, JTL Platform UI. ## Prerequisites You need: * ✅ **A JTL ID** (your login to the JTL ecosystem) * ✅ **Access to an organization (tenant) in the [Partner Portal](https://partner.jtl-cloud.com)** (created automatically on first login) * ✅ **[Node.js](https://nodejs.org/) v24.16.0 or higher** (current LTS, includes `npm`). Verify with `node --version` and `npm --version` If you don't have your JTL ID and Partner Portal access yet, follow [Create a Developer Account](/get-started/create-developer-account) first. ## What you're Building Before writing code, here’s how a JTL Cloud App works: ```mermaid theme={null} sequenceDiagram participant Shell as JTL App Shell participant FE as Your Frontend participant BE as Your Backend Shell->>FE: Loads app in iframe FE->>Shell: appBridge.method.call('getSessionToken') Shell-->>FE: Session token (JWT) FE->>BE: GET /api/connect-tenant (with token) BE-->>FE: Verified tenant info ``` Your frontend runs inside JTL’s App Shell (in an iframe). It communicates with the shell through **AppBridge**, a small SDK that exposes shell capabilities to the iframe. The most important one is `getSessionToken`, a short-lived signed token that proves which merchant (tenant) is currently using your app. Your frontend sends that token to your backend, which verifies it and uses it to make tenant-scoped calls to the JTL API. Not every page in your app runs inside the shell. The frontend has three routes, and they fall into two groups: * `/setup` and `/erp` run inside the App Shell iframe. AppBridge is available, and the app knows which tenant it's serving. * `/hub` opens in a standalone browser tab when a merchant clicks the app card. There’s no iframe, no AppBridge, and no tenant context. These two contexts require slightly different setup. You’ll handle that when building the frontend. ## 1. Create the Project ```bash theme={null} mkdir my-jtl-app cd my-jtl-app npm create vite@latest frontend -- --template react-ts ``` When prompted: * **Ok to proceed? (y):** `y` * **Install with npm and start now?:** `Yes` Once Vite finishes installing, press `Ctrl + C` to stop the dev server. ## 2. Install Packages ```bash theme={null} cd frontend npm install @tanstack/react-router @jtl-software/cloud-apps-core @jtl-software/platform-ui-react tailwindcss @tailwindcss/vite npm install -D @tanstack/router-plugin @tanstack/react-router-devtools ``` | Package | Purpose | | ----------------------------------- | ----------------------------------------------------------------------- | | `@tanstack/react-router` | Type-safe router with file-based routing | | `@tanstack/router-plugin` | Vite plugin that auto-generates the route tree from your file structure | | `@tanstack/react-router-devtools` | Devtools panel for inspecting routes (dev only) | | `@jtl-software/cloud-apps-core` | AppBridge SDK: communication between your app and the JTL App Shell | | `@jtl-software/platform-ui-react` | JTL's UI component library (Button, Card, Text, etc.) | | `tailwindcss` + `@tailwindcss/vite` | Utility-first CSS, required by the JTL UI library | ## 3. Configure Vite Replace `frontend/vite.config.ts`: ```typescript theme={null} import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite'; import path from 'path'; export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:5273', changeOrigin: true, }, }, }, plugins: [ // The router plugin must come before the React plugin tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), tailwindcss(), ], resolve: { alias: { '/assets': path.join( path.dirname(require.resolve('@jtl-software/platform-ui-react')), 'assets', ), }, }, }); ``` This does three things: * **TanStack Router plugin** watches `src/routes/` and regenerates `src/routeTree.gen.ts` whenever you add or rename a route file. The plugin must be listed before `react()`. See the [installation docs](https://tanstack.com/router/latest/docs/installation/with-vite) for details. * **Tailwind plugin** enables Tailwind utilities, which the JTL UI library depends on. * **Dev proxy** forwards `/api/*` requests to a backend on `localhost:5273`. Your frontend can call `fetch('/api/...')` and Vite will route the request without CORS issues during development. ## 4. Set up Styles Replace `frontend/src/index.css`: ```css theme={null} @import '@jtl-software/platform-ui-react/index.css'; @import 'tailwindcss'; ``` ## 5. Create the Route Files TanStack Router uses file-based routing, so your folder structure under `src/routes/` *is* your route configuration. Delete the default `src/App.tsx` (the router replaces it), then create the following structure: ``` frontend/src/routes/ ├── __root.tsx # Root layout (devtools, outlet) ├── _shell.tsx # Pathless layout. Only wraps Outlet in that renders inside the App Shell ├── _shell.setup.tsx # /setup (inside App Shell) ├── _shell.erp.tsx # /erp (inside App Shell) └── hub.tsx # /hub (standalone, no AppBridge) ``` Two conventions are in play here: * **`__root.tsx`** is the top-level layout, always rendered. * **`_shell.tsx`** is a [pathless layout route](https://tanstack.com/router/latest/docs/routing/routing-concepts#pathless-layout-routes). The leading underscore means it doesn't add a URL segment, so `_shell.setup.tsx` becomes `/setup`, not `/_shell/setup`. Any route file prefixed with `_shell.` shares the layout's wrapper. `hub.tsx` doesn't, so it bypasses the AppBridge initialization entirely. ### Root Route Add the following to your `frontend/src/routes/__root.tsx`: ```tsx theme={null} import { createRootRoute, Outlet } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; export const Route = createRootRoute({ component: () => ( <> ), }); ``` The `` is where child routes render. During development, a devtools panel appears in the bottom corner. It won’t be included in your production build. ### Shell Layout (AppBridge Provider) This layout wraps every iframe route. `frontend/src/routes/_shell.tsx`: ```tsx theme={null} import { createFileRoute, Outlet } from '@tanstack/react-router'; export const Route = createFileRoute('/_shell')({ component: () => , }); ``` Then create a `frontend/src/appBridge.tsx` file that initializes AppBridge once, exposes the bridge and tenant ID through React Context, and shows a loading state until the connection completes. ```tsx theme={null} import { createContext, useContext, useEffect, useState, type ReactNode, } from 'react'; import type { AppBridge } from '@jtl-software/cloud-apps-core'; interface AppBridgeContextValue { appBridge: AppBridge | null; tenantId: string | null; isReady: boolean; error: string | null; } const AppBridgeContext = createContext({ appBridge: null, tenantId: null, isReady: false, error: null, }); export function useAppBridge() { return useContext(AppBridgeContext); } export function AppBridgeProvider({ children }: { children: ReactNode }) { const [appBridge, setAppBridge] = useState(null); const [tenantId, setTenantId] = useState(null); const [isReady, setIsReady] = useState(false); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function init() { try { const { createAppBridge } = await import('@jtl-software/cloud-apps-core'); const bridge = await createAppBridge(); if (cancelled) return; setAppBridge(bridge); const sessionToken = await bridge.method.call('getSessionToken'); if (cancelled) return; const res = await fetch('/api/connect-tenant', { headers: { 'X-Session-ID': sessionToken }, }); if (cancelled) return; if (!res.ok) { const data = await res.json().catch(() => null); throw new Error( data?.error || `Connect failed (${res.status})`, ); } const data = await res.json(); if (cancelled) return; setTenantId(data.tenantId); setIsReady(true); } catch (err) { if (cancelled) return; console.error('[AppBridge] Init failed:', err); setError( err instanceof Error ? err.message : 'Failed to initialize', ); setIsReady(true); } } init(); return () => { cancelled = true; }; }, []); if (!isReady) { return (

Connecting to JTL Platform...

); } return ( {children} ); } ``` Two things to note: * **Dynamic import** of `createAppBridge` keeps the SDK out of any pre-render path. AppBridge requires `window`, so it can only run client-side. * **React Context** exposes `appBridge`, `tenantId`, and connection state through the `useAppBridge()` hook. Any route that is a child of the `_shell` route can call it with no prop drilling and a single shared connection state. ### Setup Page Add the following to your `frontend/src/routes/_shell.setup.tsx`: ```tsx theme={null} import { createFileRoute } from '@tanstack/react-router'; import { Button, Box, Text } from '@jtl-software/platform-ui-react'; import { useAppBridge } from '../appBridge'; function SetupPage() { const { appBridge, tenantId, error } = useAppBridge(); const handleSetupCompleted = async () => { if (!appBridge) return; try { await appBridge.method.call('setupCompleted'); } catch (err) { console.error('Setup completion failed:', err); } }; if (error) { return ( Waiting for backend The frontend is running, but no backend is responding at /api/connect-tenant. Start your backend and reload this page. {error} ); } return ( Connect to JTL Cloud Your app has been verified and is ready to connect. {tenantId && ( Tenant: {tenantId} )}