> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Your First App

> Step-by-step guide to building a LaunchMyStore app

# Building Your First App

This guide walks you through building a complete LaunchMyStore app from scratch. By the end, you'll have a working embedded app that can read products and display them in the admin.

## What We're Building

A simple "Product Insights" app that:

* Authenticates via OAuth
* Embeds in the LaunchMyStore admin
* Fetches and displays product data
* Uses App Bridge for native UI elements

## Prerequisites

* Node.js 18 or higher
* A LaunchMyStore developer account
* Basic knowledge of JavaScript/React

## Part 1: Project Setup

### Create the Project

```bash theme={null}
mkdir product-insights-app
cd product-insights-app
npm init -y
```

### Install Dependencies

```bash theme={null}
npm install express dotenv @launchmystore/app-bridge
npm install -D nodemon
```

### Project Structure

```
product-insights-app/
├── .env
├── package.json
├── server.js
├── public/
│   └── app.html
└── lib/
    └── session.js
```

## Part 2: Configure Environment

Create a `.env` file:

```env theme={null}
LMS_CLIENT_ID=your_client_id
LMS_CLIENT_SECRET=your_client_secret
LMS_APP_URL=https://your-tunnel-url.ngrok.io
LMS_SCOPES=read_products,read_orders
PORT=3000
```

<Tip>
  Use [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/) to expose your local server during development.
</Tip>

### Where secrets live

Your app's backend runs on **your own infrastructure** — LaunchMyStore never
hosts or executes your server code. That means every server-side secret
(`LMS_CLIENT_SECRET`, third-party provider keys like `OPENAI_API_KEY`,
database URLs) is stored and injected by **your hosting platform's**
environment/secret manager (Vercel, Railway, Fly, AWS Secrets Manager, …),
exactly as for any web service you deploy. There is no LaunchMyStore-side
secret vault, and no dashboard field should ever contain a provider key:

* The per-installation [settings bag](/api-reference/app-settings/get) and
  extension block settings are **merchant-visible** (and block settings can
  render into public storefront output) — configuration, not secrets.
* Nothing the platform sends your frontend (session tokens, `host` param,
  extension context, app metadata) should embed secrets, and nothing needs
  to: the embedded iframe calls **your** backend with a
  [session token](/app-bridge/session-tokens), your backend verifies it and
  then talks to your providers with keys from its own environment.
* Rotating a provider key is therefore entirely on your side — no app
  rebuild, reinstall or LaunchMyStore change involved. The one secret the
  platform does hold, your `client_secret`, can be regenerated any time in
  the Developer Portal (App → Settings → Regenerate secret) and takes
  effect immediately.
* Use separate LaunchMyStore apps (separate `client_id`/`client_secret`
  pointing at your staging URL) for dev/staging vs production.

## Part 3: Build the Server

### server.js

Your app gets its access token through the managed
[install handoff](/getting-started/install-handoff): when a merchant
installs the app, LaunchMyStore redirects their browser to your `/auth`
endpoint with an HMAC-signed query string containing a pre-authorized
`code`. There is no app-initiated redirect to `/apps/oauth/authorize` —
that endpoint is a merchant-authenticated JSON API used by the admin.

```javascript theme={null}
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const { saveSession, getSession } = require('./lib/session');

const app = express();
app.use(express.json());
app.use(express.static('public'));

const {
  LMS_CLIENT_ID,
  LMS_CLIENT_SECRET,
  PORT = 3000
} = process.env;

// Install handoff: LaunchMyStore redirects the merchant here on install.
// See /getting-started/install-handoff for the full contract.
app.get('/auth', async (req, res) => {
  const params = req.query;

  // 1. Verify the HMAC (raw query string minus the hmac= field)
  const qs = req.url.split('?')[1]
    .split('&')
    .filter((p) => !p.startsWith('hmac='))
    .join('&');
  const expected = crypto
    .createHmac('sha256', LMS_CLIENT_SECRET)
    .update(qs)
    .digest('hex');
  const a = Buffer.from(String(params.hmac));
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Invalid HMAC');
  }
  if (Date.now() - Number(params.timestamp) > 5 * 60 * 1000) {
    return res.status(401).send('Expired');
  }

  try {
    // 2. Exchange the pre-authorized code for tokens
    const tokenResponse = await fetch('https://api.launchmystore.io/apps/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: LMS_CLIENT_ID,
        client_secret: LMS_CLIENT_SECRET,
        code: params.code,
        grant_type: 'authorization_code',
        state: params.state          // echo the handoff state verbatim
      })
    });

    // Token payload is wrapped in the platform envelope — read `data`
    const body = await tokenResponse.json();
    if (!tokenResponse.ok || !body.data) {
      throw new Error(body.message || 'Token exchange failed');
    }
    const tokens = body.data;

    // 3. Save session — key by the immutable storeId, not the shop host
    await saveSession(params.storeId, {
      shop: params.shop,
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      expiresAt: Date.now() + (tokens.expires_in * 1000),
      scope: tokens.scope
    });

    // 4. Land the merchant back in their admin (host is base64)
    const adminUrl = Buffer.from(params.host, 'base64').toString('utf8');
    res.redirect(adminUrl);

  } catch (error) {
    console.error('OAuth error:', error);
    res.status(500).send('Authentication failed');
  }
});

// App entry point (loaded in the admin iframe)
app.get('/app', async (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'app.html'));
});

// API proxy to fetch products
app.get('/api/products', async (req, res) => {
  const { storeId } = req.query;
  
  const session = await getSession(storeId);
  if (!session) {
    return res.status(401).json({ error: 'Not authenticated — install the app first' });
  }
  
  try {
    const response = await fetch('https://api.launchmystore.io/api/v1/products.json', {
      headers: {
        'Authorization': `Bearer ${session.accessToken}`,
        'Content-Type': 'application/json'
      }
    });
    
    const data = await response.json();
    res.json(data);
    
  } catch (error) {
    console.error('API error:', error);
    res.status(500).json({ error: 'Failed to fetch products' });
  }
});

app.listen(PORT, () => {
  console.log(`App running on http://localhost:${PORT}`);
});
```

### lib/session.js

Simple in-memory session storage keyed by the immutable `storeId`
(use a database in production):

```javascript theme={null}
const sessions = new Map();

async function saveSession(storeId, data) {
  sessions.set(storeId, data);
}

async function getSession(storeId) {
  return sessions.get(storeId);
}

async function deleteSession(storeId) {
  sessions.delete(storeId);
}

module.exports = { saveSession, getSession, deleteSession };
```

## Part 4: Build the Frontend

### public/app.html

```html theme={null}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Product Insights</title>
  <!-- There is no public CDN build of App Bridge. Bundle
       @launchmystore/app-bridge yourself (esbuild/webpack) and expose
       createApp on window, e.g.:
       npx esbuild bridge-entry.js --bundle --outfile=public/app-bridge.js -->
  <script src="/app-bridge.js"></script>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; background: #f6f6f7; }
    .card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
    .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
    h1 { font-size: 20px; font-weight: 600; }
    .btn { background: #008060; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
    .btn:hover { background: #006e52; }
    .product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
    .product { display: flex; gap: 12px; align-items: center; }
    .product img { width: 50px; height: 50px; object-fit: cover; border-radius: 6px; }
    .product-info h3 { font-size: 14px; font-weight: 500; }
    .product-info p { font-size: 12px; color: #6b7280; }
    .loading { text-align: center; padding: 40px; color: #6b7280; }
  </style>
</head>
<body>
  <div class="header">
    <h1>Product Insights</h1>
    <button class="btn" onclick="refreshProducts()">Refresh</button>
  </div>
  
  <div class="card">
    <div id="products" class="loading">Loading products...</div>
  </div>

  <script>
    // Initialize App Bridge (dispatch takes the ACTION NAME first,
    // then the payload — never a { type, payload } object)
    const urlParams = new URLSearchParams(window.location.search);
    const host = urlParams.get('host');
    const storeId = urlParams.get('storeId');
    
    const app = window.LMSAppBridge.createApp({
      apiKey: 'YOUR_CLIENT_ID', // Replace with your Client ID
      host: host
    });
    
    // Show the admin loading bar
    app.dispatch('LOADING_START');
    
    // Fetch and display products.
    // NOTE: for production, authenticate iframe → backend calls with
    // session tokens instead of a query param — see /app-bridge/session-tokens.
    async function loadProducts() {
      try {
        const response = await fetch(`/api/products?storeId=${storeId}`);
        const data = await response.json();
        
        app.dispatch('LOADING_STOP');
        
        if (data.error) {
          throw new Error(data.error);
        }
        
        renderProducts(data.products || []);
        
      } catch (error) {
        app.dispatch('TOAST_SHOW', { message: 'Failed to load products', type: 'error' });
        document.getElementById('products').innerHTML = 'Failed to load products';
      }
    }
    
    function renderProducts(products) {
      const container = document.getElementById('products');
      
      if (products.length === 0) {
        container.innerHTML = '<p>No products found</p>';
        return;
      }
      
      container.className = 'product-grid';
      container.innerHTML = products.map(product => `
        <div class="product">
          <img src="${product.image?.src || 'https://via.placeholder.com/50'}" alt="${product.title}">
          <div class="product-info">
            <h3>${product.title}</h3>
            <p>${product.variants?.[0]?.price || 'N/A'}</p>
          </div>
        </div>
      `).join('');
    }
    
    function refreshProducts() {
      app.dispatch('TOAST_SHOW', { message: 'Refreshing products...', type: 'info' });
      loadProducts();
    }
    
    // Load products on page load
    loadProducts();
  </script>
</body>
</html>
```

## Part 5: Run and Test

### Start the Server

```bash theme={null}
npx nodemon server.js
```

### Expose with ngrok

```bash theme={null}
ngrok http 3000
```

### Update Your App Settings

1. Go to your app in the developer dashboard
2. Update the App URL to your ngrok URL — the platform redirects
   merchants to `{appUrl}/auth` at install time

### Install the App

In the Developer Portal, open your app and click **Install on My Store**
(private apps) or install it from the marketplace listing (published
public apps). LaunchMyStore redirects the browser to your
`https://your-ngrok-url/auth` handler with the signed `code` — that is
where your server exchanges it for tokens.

## Next Steps

<CardGroup cols={2}>
  <Card title="Add Extensions" icon="puzzle-piece" href="/extensions/overview">
    Extend storefronts and checkout
  </Card>

  <Card title="Add Functions" icon="code" href="/functions/overview">
    Custom shipping and payment logic
  </Card>

  <Card title="Webhooks" icon="bell" href="/api-reference/webhooks/overview">
    React to store events in real-time
  </Card>

  <Card title="Billing" icon="credit-card" href="/billing/pricing-models">
    Monetize your app
  </Card>
</CardGroup>
