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.

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 guide
  • A finished backend from either the Node.js or C# guide
  • JTL-Wawi installed and running locally, with at least one product, and connected to JTL Cloud.
If you don’t have JTL-Wawi connected to JTL Cloud yet, follow the step-by-step guide: Connect JTL-Wawi to JTL Cloud.

What you’re Building

So far the frontend and backend exist in isolation. The frontend renders, the backend verifies tokens, but nothing connects them to a real merchant. This page closes the loop. 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 root of my-jtl-app (alongside the frontend and Backend folders):
{
  "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 fieldPage in your code
manifest.lifecycle.configurationUrl_shell.setup.tsx
manifest.capabilities.hub.appLauncher.redirectUrlhub.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

1

Open the Partner Portal

Go to Partner Portal and log in.
2

Create a New App

Click + Create. You’ll see a manifest editor with a pre-filled example.
3

Paste your Manifest

Replace the example manifest with the contents of your manifest.json file. Click Register app.Partner Portal app creation screenshot
4

Copy your Credentials

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

4. Run Both Processes

You need both the frontend and backend running. Open two terminals from the project root. Terminal 1 (backend):
cd backend
npm run dev
Terminal 2 (frontend):
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

1

Open JTL Hub

Go to JTL Hub and log in.
2

Find your App

Navigate to Apps in development. You should see your newly registered app.Discover apps
3

Install the App

Click the Install button. The Hub loads your setup page inside the App Shell.Installation set up page on Iframe
4

Complete the Setup

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 real tenant ID. The session token verification round-tripped successfully through your backend, and the App Shell knows the install is 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 reference for details.
Add the items route to backend/src/server.ts. Place it alongside the existing connect-tenant route:
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.

7. Display Items in the ERP Page

Replace frontend/src/routes/_shell.erp.tsx with a version that fetches and renders the products:
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<Item[]>([]);
	const [totalCount, setTotalCount] = useState(0);
	const [loading, setLoading] = useState(false);
	const [error, setError] = useState<string | null>(null);

	const fetchItems = useCallback(async () => {
		if (!appBridge) return;
		try {
			setLoading(true);
			const sessionToken =
				await appBridge.method.call<string>('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 (
			<Box className='p-8'>
				<Text type='h2' weight='bold' color='danger' as='h1'>
					Error
				</Text>
				<Box className='mt-2'>
					<Text type='body' color='muted'>
						{error}
					</Text>
				</Box>
			</Box>
		);
	}

	return (
		<Box className='p-8'>
			<Box className='mb-4'>
				<Text type='h2' weight='bold' as='h1'>
					My JTL Cloud App
				</Text>
			</Box>
			<Box className='mb-6'>
				<Text type='body' color='muted'>
					Connected to tenant:{' '}
					<code className='bg-gray-100 px-2 py-1 rounded'>
						{tenantId}
					</code>
				</Text>
			</Box>

			<Box className='mb-3'>
				<Text type='h3' weight='semibold' as='h2'>
					Products from Cloud-ERP
				</Text>
			</Box>

			{loading ? (
				<Text type='body' color='muted'>
					Loading products...
				</Text>
			) : items.length === 0 ? (
				<Text type='body' color='muted'>
					No products found. Make sure your test account has items in
					Cloud-ERP.
				</Text>
			) : (
				<>
					<Box className='mb-3'>
						<Text type='small' color='muted'>
							Showing {items.length} of {totalCount} products
						</Text>
					</Box>
					<table className='w-full text-left border-collapse'>
						<thead>
							<tr className='border-b border-gray-200'>
								<th className='py-5 pr-8 font-semibold'>sku</th>
								<th className='py-5 pr-8 font-semibold'>
									name
								</th>
							</tr>
						</thead>
						<tbody>
							{items.map((item) => (
								<tr
									key={item.id}
									className='border-b border-gray-200'
								>
									<td className='py-2 text-sm pr-8'>
										{item.sku}
									</td>
									<td className='py-2 text-sm pr-8'>
										{item.name}
									</td>
								</tr>
							))}
						</tbody>
					</table>
				</>
			)}
		</Box>
	);
}

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:
{
  "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, 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. That’s the full handshake: the App Shell loads your frontend in an iframe, AppBridge supplies a short-lived session token, the backend verifies the token and uses its own access token to make a tenant-scoped call to the JTL Cloud API, and the result lands in 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:

Test your App

Validate your app in the sandbox with test data.

Using Platform APIs

Use the JTL-Wawi REST and GraphQL APIs, handle responses, and work with tenant-scoped data.

GraphQL Playground

Try queries and mutations interactively against your ERP instance.

App Shell & UI

Learn how to integrate deeper with the JTL UI and App Shell.

Submit to the App Store

Deploy your app and publish it for merchants.