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}
×tamp={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. ef10744c-5c4a-4f47-85fc-062ba44afb5f | 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.
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 consent | Yes — full handoff with code |
| Merchant clicks Reinstall on an app already installed | Yes — fresh code issued |
| 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 |
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:
| 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=. |
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. |