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

# Install handoff (/auth)

> The /auth handoff your app serves to receive merchant installs from the LaunchMyStore marketplace

# Install handoff (`/auth`)

When a merchant clicks **Install** on your app in the LaunchMyStore marketplace and confirms the consent screen, we redirect their browser to your app's `/auth` endpoint with an HMAC-signed query string. Your `/auth` handler is the entry point for every install — it verifies the request is genuinely from us, exchanges a pre-authorized code for an access token, and lands the merchant back inside their admin.

The same handoff fires for **private apps** installed via the Developer
Portal's **Install on My Store** button — there is no consent screen (you
are installing on your own store), but the browser is redirected to your
`/auth` URL with the same `code`/`state`/`hmac` parameters. This is how a
private app obtains its first Admin API access token; no marketplace
publication is required.

If your app's `appUrl` is an external URL (anything starting with `https://`), you must serve `/auth`. First-party apps with local `/marketplace-apps/...` URLs do not receive this redirect and stay embedded directly.

## The redirect we send

After the merchant approves install, LaunchMyStore navigates the browser to:

```
GET {appUrl}/auth
  ?shop={storefront-host}
  &storeId={uuid}
  &code={oauth-code}
  &state={csrf-state}
  &host={base64-admin-url}
  &timestamp={epoch-ms}
  &hmac={hex-sha256-signature}
```

### Parameters

| Parameter   | Shape                                                   | What it is                                                                                                | When to use it                                                                                           |
| ----------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `shop`      | `mystore.launchmystore.io` or `www.merchant-domain.com` | Full storefront host. **Mutable** — a merchant can rename their storeURL or attach a custom domain.       | Display the merchant's shop name in your UI. **Do not** use as a primary key.                            |
| `storeId`   | UUID, e.g. `3fa85f64-5717-4562-b3fc-2c963f66afa6`       | Our internal, **immutable** merchant identifier.                                                          | **The primary key** for any per-merchant data you store (access tokens, BYOK settings, install records). |
| `code`      | 64-char hex                                             | Pre-authorized OAuth code. 10-minute TTL. Already scoped to the merchant + the scopes you registered.     | Exchange immediately at `POST /apps/oauth/token` to get an `access_token`.                               |
| `state`     | 64-char hex                                             | CSRF binding. You must echo this back at the token exchange call.                                         | Pass through verbatim — do not log or persist outside the exchange.                                      |
| `host`      | Base64 of admin URL                                     | The merchant's admin URL, base64-encoded. Decodes to e.g. `http://admin.launchmystore.io/admin/apps/seo`. | After your `/auth` handler finishes, redirect the merchant here to land them in the embedded admin view. |
| `timestamp` | Epoch milliseconds                                      | When we generated the redirect.                                                                           | Reject requests older than 5 minutes — protects against replay even if the HMAC leaks.                   |
| `hmac`      | Lowercase hex SHA-256                                   | HMAC of the querystring (without `&hmac=`) using your app's `clientSecret`.                               | Verify before trusting any other parameter. Use constant-time comparison.                                |

## Step 1: Verify the HMAC

Reconstruct the querystring without the `hmac` field, compute HMAC-SHA256 with your `clientSecret`, and compare in constant time.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const app = express();

  app.get('/auth', async (req, res) => {
    const { hmac, ...params } = req.query;

    // Rebuild the querystring without `hmac=` — alphabetical sort is NOT
    // required by us, but you must preserve the original order. Easiest:
    // strip `hmac=` from the raw query string.
    const qs = req.url.split('?')[1]
      .split('&')
      .filter((p) => !p.startsWith('hmac='))
      .join('&');

    const expected = crypto
      .createHmac('sha256', process.env.LMS_CLIENT_SECRET)
      .update(qs)
      .digest('hex');

    const actual = Buffer.from(String(hmac), 'utf8');
    const expectedBuf = Buffer.from(expected, 'utf8');

    if (
      actual.length !== expectedBuf.length ||
      !crypto.timingSafeEqual(actual, expectedBuf)
    ) {
      return res.status(401).send('Invalid HMAC signature');
    }

    // Replay defence — reject anything older than 5 minutes
    const age = Date.now() - Number(params.timestamp);
    if (Number.isNaN(age) || age > 5 * 60 * 1000) {
      return res.status(401).send('Request expired');
    }

    // ... continue to step 2
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  from urllib.parse import urlparse, parse_qsl
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.route('/auth')
  def auth():
      parsed = urlparse(request.url)
      pairs = parsed.query.split('&')
      qs_without_hmac = '&'.join(p for p in pairs if not p.startswith('hmac='))

      expected = hmac.new(
          os.environ['LMS_CLIENT_SECRET'].encode(),
          qs_without_hmac.encode(),
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(expected, request.args.get('hmac', '')):
          abort(401)

      age_ms = (time.time() * 1000) - int(request.args.get('timestamp', 0))
      if age_ms > 5 * 60 * 1000:
          abort(401)

      # ... continue to step 2
  ```

  ```ruby Ruby theme={null}
  require 'openssl'
  require 'sinatra'

  get '/auth' do
    pairs = request.query_string.split('&')
    qs_without_hmac = pairs.reject { |p| p.start_with?('hmac=') }.join('&')

    expected = OpenSSL::HMAC.hexdigest(
      'sha256',
      ENV['LMS_CLIENT_SECRET'],
      qs_without_hmac,
    )

    halt 401 unless Rack::Utils.secure_compare(expected, params['hmac'].to_s)

    age_ms = (Time.now.to_f * 1000) - params['timestamp'].to_i
    halt 401 if age_ms > 5 * 60 * 1000

    # ... continue to step 2
  end
  ```
</CodeGroup>

<Warning>
  Always use constant-time comparison (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python, `Rack::Utils.secure_compare` in Ruby). String `==` leaks timing information.
</Warning>

## Step 2: Exchange the code for an access token

Once HMAC is verified, call our token endpoint with your `client_id`, `client_secret`, the `code`, and the `state`. No merchant session cookie is needed — your client credentials are the auth.

```http theme={null}
POST https://api.launchmystore.io/apps/oauth/token
Content-Type: application/json

{
  "client_id": "lms_app_xxxxxxxxxxxx",
  "client_secret": "<your client secret>",
  "code": "<code from query string>",
  "state": "<state from query string>",
  "grant_type": "authorization_code"
}
```

Response — the token object is wrapped in the platform envelope
(`{ status, state, message, data }`); read the fields from `data`:

```json theme={null}
{
  "status": 200,
  "state": "success",
  "message": null,
  "data": {
    "access_token": "lms_token_xxxxxxxxxxxxxxxxxxxxxxxx",
    "refresh_token": "lms_refresh_xxxxxxxxxxxxxxxxxxxxxxxx",
    "token_type": "bearer",
    "expires_in": 86400,
    "scope": "read_products write_products"
  }
}
```

The `code` is single-use. A second exchange attempt returns `400 Invalid or expired authorization code`.

## Step 3: Persist the install record

Store the install **keyed by `storeId`**, not `shop`:

```javascript theme={null}
const { data: token } = await tokenRes.json();   // unwrap the envelope

await db.installs.upsert({
  storeId: params.storeId,          // primary key — immutable UUID
  shop: params.shop,                  // mutable, for display only
  accessToken: token.access_token,
  refreshToken: token.refresh_token,
  scopes: token.scope.split(' '),
  installedAt: new Date(),
});
```

The merchant may later change their shop domain (rename `mystore.launchmystore.io` or attach `www.merchant-domain.com`). If you keyed off `shop` you would lose every install on rename. `storeId` never changes.

## Step 4: Land the merchant in their admin

Decode the `host` parameter to get the URL of the merchant's embedded admin view, then redirect there:

```javascript theme={null}
// `host` is present for marketplace and Developer-Portal installs, but may
// be absent if the install was triggered by a raw API call that didn't
// supply it — guard before decoding so a missing host doesn't 500 your
// handler. Fall back to the merchant's admin apps list.
const adminUrl = params.host
  ? Buffer.from(params.host, 'base64').toString('utf8')
  : 'https://app.launchmystore.io/admin/apps';
// e.g. "http://admin.launchmystore.io/admin/apps/your-handle"
res.redirect(adminUrl);
```

The merchant lands inside their LaunchMyStore admin with your app embedded as an iframe. From there your app authenticates per-request via [session tokens](/app-bridge/session-tokens) — no further OAuth round trip is needed for normal use.

## Full reference implementation

```javascript theme={null}
import crypto from 'crypto';
import express from 'express';

const app = express();
const { LMS_CLIENT_ID, LMS_CLIENT_SECRET } = process.env;

app.get('/auth', async (req, res) => {
  const params = req.query;

  // 1. Verify HMAC
  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');
  }

  // 2. Exchange code
  const tokenRes = 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,
      state: params.state,
      grant_type: 'authorization_code',
    }),
  });
  if (!tokenRes.ok) {
    return res.status(502).send('Token exchange failed');
  }
  const { data: token } = await tokenRes.json();   // unwrap { status, state, data }

  // 3. Persist install (keyed by storeId — immutable)
  await db.installs.upsert({
    storeId: params.storeId,
    shop: params.shop,
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    scopes: token.scope.split(' '),
    installedAt: new Date(),
  });

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

## When `/auth` is and isn't called

| Situation                                                                       | `/auth` called?                                                                                                                                                                                                                                  |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Merchant clicks **Install** in the marketplace, confirms consent                | **Yes** — full handoff with code                                                                                                                                                                                                                 |
| Developer clicks **Install on My Store** in the Developer Portal (private apps) | **Yes** — same handoff, no consent screen                                                                                                                                                                                                        |
| Merchant clicks **Reinstall** on an app already installed                       | **Yes** — fresh code issued                                                                                                                                                                                                                      |
| Merchant installs a **paid app without a free trial**                           | **Deferred** — the install first returns `{ requiresPayment, checkoutUrl }`; the handoff fires after Stripe checkout completes (the admin redeems it via `GET /apps/store/launch-url/:appId` when the merchant lands back on `?billing=success`) |
| Merchant opens the app from their admin sidebar                                 | No — direct iframe to your `appUrl`, no code                                                                                                                                                                                                     |
| First-party app with local `/marketplace-apps/...` URL                          | No — these don't have an `/auth` route                                                                                                                                                                                                           |
| App declared with no external `appUrl`                                          | No — install completes, no redirect                                                                                                                                                                                                              |

<Tip>
  **Lost your access token?** The merchant admin can re-issue the handoff for
  an active install via `GET /apps/store/launch-url/:appId` (merchant-authed) —
  opening the returned URL fires your `/auth` endpoint with a fresh single-use
  code. Uninstall + reinstall also works. There is no separate "retrieve my
  token" endpoint; tokens are only ever delivered through this handoff, so
  persist the `access_token` + `refresh_token` pair durably (encrypted at
  rest, keyed by `storeId`).
</Tip>

For the merchant-opens-app case, use [session tokens](/app-bridge/session-tokens) to authenticate per-request. The `/auth` handoff happens once per install; session tokens happen on every render.

## Parameter quick reference

For convenience, the redirect query parameters at a glance:

| Parameter   | Shape                     | Purpose                                                     |
| ----------- | ------------------------- | ----------------------------------------------------------- |
| `shop`      | Storefront host (mutable) | Display only                                                |
| `storeId`   | UUID (immutable)          | Primary key for per-merchant storage                        |
| `code`      | 64-char hex               | Single-use OAuth code, 10-minute TTL                        |
| `state`     | 64-char hex               | CSRF binding, echo at token exchange                        |
| `host`      | Base64 admin URL          | Decode and redirect back here after install                 |
| `timestamp` | Epoch milliseconds        | Replay defence — reject if older than 5 minutes             |
| `hmac`      | Hex SHA-256               | HMAC of querystring (without `&hmac=`) using `clientSecret` |

The contract uses standard OAuth 2.0 (RFC 6749) authorization code grant semantics plus an HMAC-signed install redirect. Any HTTP framework can implement it in under 50 lines.

## Common errors

| Symptom                                                                                                                                                                                      | Likely cause                                                                                                                                                                                                                                                |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 Invalid HMAC signature` from your own handler                                                                                                                                           | You re-encoded the query string before HMACing. Sign the raw bytes between `?` and `&hmac=`.                                                                                                                                                                |
| HMAC **never** verifies (correct secret, raw query string) — or the `/auth` redirect stopped firing entirely, or session tokens fail with `409 credentials predate plaintext secret storage` | Your app was created before the platform stored secrets in signable form. **Regenerate the client secret** in the Developer Portal (App → Settings → Regenerate secret), update the secret your app stores, and retry. Existing access tokens keep working. |
| `400 Invalid or expired authorization code` on token exchange                                                                                                                                | You tried to reuse a code. Each code is single-use.                                                                                                                                                                                                         |
| `400 Invalid state parameter` on token exchange                                                                                                                                              | You modified or didn't echo `state`. Pass through verbatim.                                                                                                                                                                                                 |
| Merchant lands on a blank page                                                                                                                                                               | You called `res.redirect(host)` without base64-decoding. Decode `host` first.                                                                                                                                                                               |
| You lose every install when a merchant renames their shop                                                                                                                                    | You keyed installs off `shop`. Use `storeId`.                                                                                                                                                                                                               |

## Related

* [OAuth Authorize](/api-reference/oauth/authorize) — the underlying authorize endpoint (for app-initiated OAuth flows outside install)
* [OAuth Token](/api-reference/oauth/token) — token exchange + refresh
* [Session Tokens](/app-bridge/session-tokens) — per-request auth for embedded surfaces
* [App Lifecycle](/getting-started/app-lifecycle) — install / uninstall / version-bump
