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

# Storefront app proxy

> Serve your app's own pages and endpoints under /apps/<slug> on a merchant's storefront, signed and without shopper cookies

Your app can answer requests on the merchant's own storefront, at
`https://<store-domain>/apps/<slug>/<rest>`, without the merchant editing their
theme. Salesive forwards the request to your app's `proxyUrl` and returns your
app's answer to the shopper.

That is how an app serves a booking form, a product finder or a small JSON
endpoint on the store's own domain. The `/apps` prefix is reserved: a request
under it with no active installation of a matching app is a plain `404`, never
the storefront's page.

> [!NOTE]
>
> This is the **storefront**, not the dashboard. `/apps` on `app.salesive.com`
> is the dashboard's own route and has nothing to do with this proxy.

## How a request flows

1. A shopper opens `https://shop.example.com/apps/reviews/stars?product=123`.
2. Salesive finds the active installation of the app whose slug is `reviews` on
   that store.
3. It builds `https://apps.example.com/reviews/stars?product=123` from the
   app's `proxyUrl` (`https://apps.example.com/reviews`), the rest of the path
   and the query string, and sends a `GET` to it.
4. Your app answers. Salesive passes the status, the content type and the body
   back to the shopper.

## Set the proxy URL

`proxyUrl` is an optional HTTPS base URL on your app. In the developer console,
open your app, go to the **Configuration** tab, choose **Production**, and fill
**App proxy URL**. After you save it, the console shows a copyable example URL on
your current store so you can check the path.

You can also send it with the app update endpoint, using your merchant session:

```bash theme={null}
curl -X PUT https://api.salesive.com/api/v1/apps/developer/<appId> \
  -H "Authorization: Bearer <your-merchant-session-token>" \
  -H "Content-Type: application/json" \
  -d '{ "proxyUrl": "https://apps.example.com/reviews" }'
```

* The base path is kept. `https://apps.example.com/reviews` plus `/stars`
  becomes `https://apps.example.com/reviews/stars`.
* A query string already on the proxy URL is kept, and the incoming query is
  appended after it.
* Send `""` or `null` to remove it.
* The URL must be HTTPS, must not contain credentials, and must not resolve to
  a private, loopback or link local address. Salesive checks it with the same
  guard as a [webhook URL](https://docs.salesive.com/apps-api/webhooks.md#enable-webhooks) when you save it.
* One URL serves every install, unless a [release](https://docs.salesive.com/apps/releases.md) pins a
  different one.

### Releases and development installs

`proxyUrl` is part of the [release](https://docs.salesive.com/apps/releases.md) snapshot, next to the
webhook URL and signing secret. An install pinned to a release keeps that
release's proxy URL and secret, even after you edit the app. Publishing a
release writes its snapshot back onto the app for consumers that do not read
releases.

There is no development proxy URL. A development install uses the same
`proxyUrl` and the production signing secret, so point it at a public HTTPS host
while you test (a tunnel works), not at your machine.

## What your app receives

### Methods

Only `GET` and `POST`. Any other method is `405` with `Allow: GET, POST`.

### URL

Your `proxyUrl` base path, the rest of the path, and the shopper's query string.
Dot segments (`.` and `..`) in the path are refused, so a request cannot walk
out of your base path.

### Headers

| Header | Value |
| - | - |
| `X-Salesive-Shop` | The store id. |
| `X-Salesive-Shop-Domain` | The host the shopper used, such as `shop.example.com`. |
| `X-Salesive-App` | Your app's slug. |
| `X-Salesive-Signature` | `t=<unix seconds>,v1=<signature>`, see below. |

The only headers forwarded from the shopper are `Content-Type`, `Accept` and
`User-Agent`. Everything else stays on the storefront. In particular, your app
never receives the shopper's `Cookie`, `Authorization`, `Referer`, IP address or
any `X-Forwarded-*` header.

> [!WARNING]
>
> The app cannot see the shopper's session, and it cannot set a cookie either:
> `Set-Cookie` is not forwarded back. Every proxy request is anonymous apart
> from the store and app identity in the signed headers. If a page needs to know
> who the shopper is, have the theme or an app block pass a token in the query
> or body, or keep the state in your own backend.

### Body

A `POST` body is forwarded as the exact bytes received, with the `Content-Type`
the shopper sent. Any content type is accepted. The body limit is 5 MB; a larger
body is `413`.

## Verify the signature

The proxy is signed with your app's webhook signing secret, using the same
construction as [webhook signature v2](https://docs.salesive.com/apps-api/webhooks.md#verify-the-signature):
base64 HMAC-SHA256 of `<t>.<raw body>`, in
`X-Salesive-Signature: t=<unix seconds>,v1=<signature>`.

* Verify the raw bytes before parsing.
* Reject a `t` more than 300 seconds from your clock, in either direction.
* A `GET` has an empty body, so the signed string is `<t>.` followed by nothing.

```js Node.js (Express) theme={null}
import express from "express";
import crypto from "crypto";

const app = express();
const SIGNING_SECRET = process.env.SALESIVE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

const safeEqual = (a, b) => {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && crypto.timingSafeEqual(left, right);
};

// Buffer every content type raw, because the signature covers the bytes.
app.use("/apps", express.raw({ type: () => true }));

app.use("/apps", (req, res) => {
  const parts = Object.fromEntries(
    (req.get("X-Salesive-Signature") || "")
      .split(",")
      .map((part) => part.split("=", 2)),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1 || "";

  if (
    !Number.isFinite(timestamp) ||
    Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS
  ) {
    return res.status(401).send("stale signature");
  }

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.`)
    .update(req.body || Buffer.alloc(0))
    .digest("base64");

  if (!safeEqual(signature, expected)) {
    return res.status(401).send("invalid signature");
  }

  const shopId = req.get("X-Salesive-Shop");
  const appSlug = req.get("X-Salesive-App");
  // ... handle the request
});
```

> [!NOTE]
>
> The signature covers the timestamp and the body, not the path or the query
> string. Treat it as proof that the request came through Salesive for this
> store and app, and still decide for yourself what a given path or argument may
> do.

## What your app returns

* The status code and the `Content-Type` pass through. The body is not
  rewritten. If your app sends no content type, the response is
  `application/octet-stream`.
* Only `Content-Type` is taken from your response headers. Your app cannot set
  cookies or custom headers on the storefront response.
* Every response, including errors, is `Cache-Control: private, no-store` and
  `X-Content-Type-Options: nosniff`.
* A `3xx` is refused. Salesive answers `502` rather than following a redirect
  the shopper never sees.
* A response over 5 MB is `502`.
* Your app has 10 seconds to answer, body included. A timeout is `504`.
* An unreachable app is `502`.

Responses are buffered before they reach the shopper, so a streaming response
(server-sent events, a long download) arrives only when it is complete.
WebSockets are not proxied.

## Limits and errors

| Situation | Answer |
| - | - |
| No active install, no `proxyUrl`, or a suspended app | `404` |
| Method other than `GET` or `POST` | `405` |
| Request body over 5 MB | `413` |
| Too many requests | `429` with `Retry-After` |
| Your app answers with a redirect | `502` |
| Your app's response is over 5 MB | `502` |
| Your app cannot be reached | `502` |
| Your app does not answer in 10 seconds | `504` |

Error bodies are plain text. The storefront limits each store and app pair to a
burst of 120 requests and a sustained 2 requests a second, and answers `429` when
the bucket is empty. The bucket is per storefront instance, so two instances
allow twice the burst.

## Uninstall and suspend

The proxy needs an active installation of an active app. When the merchant
uninstalls your app, or the app is suspended, the next request under `/apps` is
`404`. Nothing is cached, so there is no window where an old answer is served.

## Security notes

* The request comes from Salesive's storefront servers, not from the shopper's
  browser. Your endpoint must be reachable from the public internet over HTTPS.
* The signed headers are the only trusted identity. Your endpoint is also
  reachable outside the proxy, so never trust a header a caller could set
  itself.
* The proxy carries no app token and no scopes. If you need store data, call the
  [Apps API](https://docs.salesive.com/apps-api/introduction.md) with your own app token, the same as any
  other request.
* Treat the path, query and body as untrusted shopper input.

## Next steps

> [card] [Storefront extensions](https://docs.salesive.com/apps/storefront-extensions.md)
>
> Scripts, app embeds and app blocks that run inside the storefront page.

> [card] [Webhooks](https://docs.salesive.com/apps-api/webhooks.md)
>
> The same signature construction, for events Salesive sends to you.

> [card] [Releases](https://docs.salesive.com/apps/releases.md)
>
> Pin installs to the proxy URL and secret a release was tested with.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.