App Proxy
An App Proxy lets your app serve dynamic content — tracking widgets,
account dashboards, lookup forms, AJAX endpoints — from the merchant’s
own storefront domain. A buyer requests
https://store-name.launchmystore.io/apps/{your-app}/api/tracking?awb=…,
the platform signs and forwards the request to your server, and your
response is streamed back as if it came from the merchant’s domain.
App Proxy lets you ship customer-facing surfaces that:
- Read first-party cookies (cart, session, locale) — no CORS, no
third-party cookie blocking.
- Render Aqua templates with full theme context (
shop, customer,
cart, theme settings) so your app blends visually with the store.
- Look authentic to buyers — no random
*.your-domain.com iframe.
When to use App Proxy
| Use case | Why App Proxy |
|---|
Shipment tracking widget on /orders/{id} | Buyers see your status pulled live from the carrier, on the merchant’s domain. |
Customer-facing wishlist / loyalty page (/apps/loyalty/dashboard) | First-party cookies + theme styling, no iframe friction. |
AJAX endpoints called from a storefront block (e.g. fetch('/apps/reviews/api/list?productId=…')) | Same-origin → no CORS preflight, sessions just work. |
| Server-rendered Aqua pages | The platform renders your application/liquid response through the theme engine. |
If you only need a static script tag, use
App Scripts instead. If you need to render UI
inside checkout, use Checkout UI.
How it works
Buyer browser Storefront Your app server
───────────── ────────── ───────────────
GET 1. Match /apps/{handle}/* 4. Verify signature
/apps/shiprocket/api/ from query
tracking?awb=ABC123 ───► 2. Look up your appProxy ──► 5. Run handler
manifest (URL + secret) 6. Respond
3. Forward + sign: (JSON or
?shop=…&path_prefix=… application/liquid)
×tamp=…&signature=…
◄──
◄── 7. Stream body back
(Liquid responses are
rendered through the
theme first)
- The buyer’s browser hits
/apps/{your-handle}/{anything} on the
merchant’s domain.
- The storefront’s middleware rewrites the URL to the internal proxy
handler and looks up your manifest’s
extensions.appProxy block to
find the upstream URL.
- The platform signs the outbound query with HMAC-SHA256 using your
app’s
clientSecret and appends four params: shop, path_prefix,
timestamp, signature.
- Your server verifies the signature and runs the request.
- The response streams back to the buyer. If your response’s
Content-Type is application/liquid, the platform first renders it
through the merchant’s active theme with global objects (shop,
customer, cart, etc.).
Manifest
Declare a proxy in app.json under extensions.appProxy:
{
"handle": "shiprocket",
"name": "Shiprocket",
"version": "1.0.0",
"extensions": {
"appProxy": {
"url": "https://shiprocket.your-domain.app",
"subpath": "shiprocket",
"subpath_prefix": "apps",
"description": "Storefront proxy for tracking + AWB lookup."
}
}
}
| Field | Required | Description |
|---|
url | yes | The base URL of your app server. The platform appends subPath and signed params to this. May include a path prefix (e.g. https://api.acme.app/v1). |
subpath | yes | The path segment after the prefix. With subpath: "shiprocket", requests are matched at /apps/shiprocket/*. Conventionally matches your app handle. |
subpath_prefix | yes | Always apps. Reserved for future expansion (e.g. tools). |
description | no | Free-text shown in the admin app-detail page so merchants know what the proxy does. |
Only one App Proxy per app is supported. If you need to serve multiple
namespaces (tracking, returns, lookup), route them under sub-paths of the
same url (e.g. /api/tracking, /api/returns).
URL mapping
With the manifest above and a buyer request to
https://acme-store.launchmystore.io/apps/shiprocket/api/tracking?awb=ABC123:
| Value |
|---|
| Merchant storefront URL | https://acme-store.launchmystore.io/apps/shiprocket/api/tracking?awb=ABC123 |
| Your server receives | POST/GET https://shiprocket.your-domain.app/api/tracking?awb=ABC123&shop=acme-store&path_prefix=/apps/shiprocket×tamp=…&signature=… |
| HTTP method | Preserved verbatim — GET stays GET, POST stays POST. |
| Body | Forwarded verbatim with the original Content-Type. |
| Headers added | X-LMS-Shop-Domain, X-LMS-App-Handle, X-LMS-Hmac (body HMAC), X-LMS-Hmac-SHA256 (canonical-string HMAC). |
The path you read on your server is subPath only — the
/apps/{handle} prefix is consumed by the platform.
Signature (HMAC-SHA256)
Every proxied request is signed so your server can trust the shop value
and reject anyone calling your URL directly.
Signed params (always forwarded):
| Param | Value |
|---|
shop | The merchant’s domainSlug (e.g. acme-store). Stable per store. |
path_prefix | /apps/{your-subpath} (e.g. /apps/shiprocket). |
timestamp | Unix epoch seconds (string). Reject requests older than 5 minutes. |
signature | Hex-encoded HMAC-SHA256. The thing you verify. |
Any caller-supplied query param (e.g. ?awb=ABC123) is also included
in the signature, so a tampered URL fails verification.
Canonical string
The signature is computed over a canonical string built by:
- Take every query param except
signature.
- Sort the keys alphabetically (ASCII).
- Join as
key=value with no separator (no &, no ,).
- Array values are joined by
, before signing.
For the URL above, the canonical string is:
awb=ABC123path_prefix=/apps/shiprocketshop=acme-storetimestamp=1716700000
And the signature is:
signature = hex(HMAC-SHA256(canonical, your_app.clientSecret))
This is the canonical-string HMAC rule the platform uses to sign every
App Proxy request — alphabetically-sorted key=value pairs joined with
no separator, hashed with HMAC-SHA256 under your app’s clientSecret.
Verifying in your app
If your app uses the @launchmystore/apps-shared package, drop in the
ready-made middleware:
import express from 'express';
import { requireAppProxySignature } from '@launchmystore/apps-shared';
const app = express();
const requireProxy = requireAppProxySignature({
clientSecret: process.env.CLIENT_SECRET,
// Reject timestamps older than 5 minutes (default).
maxSkewSeconds: 300,
// Set true in dev so unsigned curl requests still go through.
// ALWAYS false in production.
optional: process.env.NODE_ENV !== 'production',
});
app.get('/api/tracking', requireProxy, (req, res) => {
// Verified context attached by the middleware:
// req.appProxy = { shop, pathPrefix, timestamp, verified }
const shop = req.appProxy.shop;
const awb = req.query.awb;
// ...look up the merchant's carrier creds by `shop`, call the carrier...
res.json({ status: 'In Transit', shop, awb });
});
Or verify by hand
Any HTTP framework / language. Pseudo-code:
import crypto from 'node:crypto';
function verify(query, clientSecret) {
const { signature, ...rest } = query;
if (!signature) return false;
const canonical = Object.keys(rest)
.sort()
.map((k) => {
const v = Array.isArray(rest[k]) ? rest[k].join(',') : String(rest[k]);
return `${k}=${v}`;
})
.join('');
const expected = crypto.createHmac('sha256', clientSecret)
.update(canonical)
.digest('hex');
// Constant-time compare
return signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}
Always pair this with a freshness check:
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(query.timestamp));
if (skew > 300) return false; // 5-minute window
Response types
Your app server can respond with either a regular content type or
application/liquid for server-rendered theme HTML.
JSON / HTML / text — passthrough
The body streams back to the browser verbatim. Use this for AJAX endpoints
called from your storefront block:
app.get('/api/tracking', requireProxy, async (req, res) => {
const data = await fetchCarrierStatus(req.query.awb);
res.json(data);
});
// From a storefront block's JS:
fetch('/apps/shiprocket/api/tracking?awb=' + awb)
.then(r => r.json())
.then(renderStatus);
Same-origin — no CORS, no preflight, sends first-party cookies.
application/liquid — server-rendered theme HTML
Respond with Content-Type: application/liquid and the platform renders
your body through the merchant’s active theme before sending it to the
browser. Your response can use any Aqua object,
filter, or tag:
app.get('/dashboard', requireProxy, (req, res) => {
res.setHeader('Content-Type', 'application/liquid');
res.send(`
{% layout 'theme' %}
<main class="page-wrap">
<h1>Welcome back, {{ customer.first_name | default: 'friend' }}</h1>
<p>Your loyalty points: <strong>{{ customer.metafields.app_loyalty.points }}</strong></p>
</main>
`);
});
The buyer sees a fully themed page at
https://store.launchmystore.io/apps/loyalty/dashboard that inherits the
merchant’s header, footer, fonts, and colours.
application/liquid responses are rendered with the storefront’s full
global objects — shop, customer, cart,
settings, linklists, etc. No need to pass them through; they’re
already in scope.
Common patterns
A storefront snippet on /orders/{id}
calls your proxy endpoint:
{% comment %} snippets/shiprocket-tracking.aqua {% endcomment %}
<div id="shiprocket-tracking" data-awb="{{ order.metafields.app_shiprocket.awb }}">
Loading shipment status...
</div>
<script>
(function() {
var el = document.getElementById('shiprocket-tracking');
var awb = el.dataset.awb;
if (!awb) return;
fetch('/apps/shiprocket/api/tracking?awb=' + encodeURIComponent(awb))
.then(function(r) { return r.json(); })
.then(function(d) {
el.innerHTML = '<strong>' + d.status + '</strong><br>' + d.last_scan;
});
})();
</script>
2. Customer-facing app page
A merchant-link in the storefront header points to
/apps/loyalty/dashboard. Your proxy returns application/liquid and
the platform themes it.
3. Same-origin AJAX from a storefront block
A reviews app’s block JS posts new reviews to
/apps/reviews/api/submit. Cookies sent automatically — your server
identifies the customer via req.appProxy.shop + the platform session
cookie.
Caching
The platform forwards your response’s Cache-Control header. To cache
proxied responses at the edge, set:
Cache-Control: public, max-age=60, s-maxage=300
JSON responses with Cache-Control: no-store (the default) are not
cached.
Security checklist
- ✅ Always verify the signature in production. The
optional: true
dev mode skips verification — never ship that to prod.
- ✅ Check
timestamp freshness (≤ 5 minutes). Prevents replay
attacks if a logged URL leaks.
- ✅ Use
req.appProxy.shop (verified) — never trust ?shop= from a
caller without verification.
- ✅ Don’t echo unsanitized user input into
application/liquid
responses. Liquid output is auto-escaped, but {{ x }} inside an
attribute can still break if x contains quotes. Use the escape
filter for paranoia.
- ❌ Don’t expose write endpoints (anything that mutates merchant
data) on the proxy unless you also require a signed-in customer session
— the proxy only proves the request originated from a merchant
storefront, not which buyer made it.
Development & testing
For local development, your app server typically runs on
http://localhost:PORT. Point your dev app’s extensions.appProxy.url
at it via a tunneling tool (ngrok, cloudflared, tailscale funnel) so the
platform can reach it from the cloud.
// app.json — dev variant
"extensions": {
"appProxy": {
"url": "https://your-tunnel.ngrok.app",
"subpath": "shiprocket",
"subpath_prefix": "apps"
}
}
For unit tests, the shared package exports signAppProxyUrl():
import { signAppProxyUrl } from '@launchmystore/apps-shared';
const url = signAppProxyUrl({
baseUrl: 'http://localhost:4101',
subPath: '/api/tracking',
extraQuery: { awb: 'ABC123' },
shop: 'acme-store',
pathPrefix: '/apps/shiprocket',
clientSecret: process.env.CLIENT_SECRET,
});
const res = await fetch(url);
// → exercises the same canonical-string + HMAC path the platform uses.
Limits
| Limit | Value |
|---|
| Request body size | 5 MB |
| Response body size | Unlimited (streamed) |
| Upstream timeout | 30 s |
| Concurrency per merchant | No platform cap — your server is the bottleneck |
| Allowed methods | GET, POST, PUT, PATCH, DELETE |
A timeout or 5xx from your server returns 502 Bad Gateway to the buyer.
The buyer’s browser sees that response; the storefront does not retry
on your behalf — handle retries in your app.
See also
- Storefront Snippets — call your
proxy endpoints from theme-author Liquid.
- App Scripts — when you only need a
<script>
tag on every page (no signed server endpoint).
- Webhooks — server-to-server events
from the platform to your app (the inbound direction; App Proxy is the
buyer-to-app direction).
- App Bridge: Session Tokens — equivalent
trust mechanism for admin iframes.