Skip to main content

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

ParameterShapeWhat it isWhen to use it
shopmystore.launchmystore.io or www.merchant-domain.comFull 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.
storeIdUUID, e.g. ef10744c-5c4a-4f47-85fc-062ba44afb5fOur internal, immutable merchant identifier.The primary key for any per-merchant data you store (access tokens, BYOK settings, install records).
code64-char hexPre-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.
state64-char hexCSRF binding. You must echo this back at the token exchange call.Pass through verbatim — do not log or persist outside the exchange.
hostBase64 of admin URLThe 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.
timestampEpoch millisecondsWhen we generated the redirect.Reject requests older than 5 minutes — protects against replay even if the HMAC leaks.
hmacLowercase hex SHA-256HMAC 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.
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
});
Always use constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, Rack::Utils.secure_compare in Ruby). String == leaks timing information.

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.
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:
{
  "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:
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:
const adminUrl = Buffer.from(params.host, 'base64').toString('utf8');
// 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 — no further OAuth round trip is needed for normal use.

Full reference implementation

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 token = await tokenRes.json();

  // 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 consentYes — full handoff with code
Merchant clicks Reinstall on an app already installedYes — fresh code issued
Merchant opens the app from their admin sidebarNo — direct iframe to your appUrl, no code
First-party app with local /marketplace-apps/... URLNo — these don’t have an /auth route
App declared with no external appUrlNo — install completes, no redirect
For the merchant-opens-app case, use 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:
ParameterShapePurpose
shopStorefront host (mutable)Display only
storeIdUUID (immutable)Primary key for per-merchant storage
code64-char hexSingle-use OAuth code, 10-minute TTL
state64-char hexCSRF binding, echo at token exchange
hostBase64 admin URLDecode and redirect back here after install
timestampEpoch millisecondsReplay defence — reject if older than 5 minutes
hmacHex SHA-256HMAC 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

SymptomLikely cause
401 Invalid HMAC signature from your own handlerYou re-encoded the query string before HMACing. Sign the raw bytes between ? and &hmac=.
400 Invalid or expired authorization code on token exchangeYou tried to reuse a code. Each code is single-use.
400 Invalid state parameter on token exchangeYou modified or didn’t echo state. Pass through verbatim.
Merchant lands on a blank pageYou called res.redirect(host) without base64-decoding. Decode host first.
You lose every install when a merchant renames their shopYou keyed installs off shop. Use storeId.