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

# Customer account extensions

> Add a block from your app to a signed in shopper's account area, with a short lived signed URL your page verifies before it acts.

## Overview

A customer account extension is one block your app adds to the account area of a
signed in shopper. The storefront theme asks the Store API for the blocks on the
current store, shows a card for each one, and opens your page in an iframe when
the shopper chooses it.

Your page is not given the shopper's session cookie. Instead, the theme opens it
with a short lived signed URL that names the store and the shopper. The
signature is made with your app's webhook signing secret, which never leaves the
platform, and it tells you which shopper the block is acting for. Verify it
before you read or change anything.

The registration shape is the same as the checkout extensions: one row per
installation, registered with your app token, with a merchant switch.

## Register a block

An installed app registers one block per installation with its own `app_` token.
The registration is the `customer-account` kind of the checkout extension
registry (`AppCheckoutExtension`), so it shares the row shape and the merchant
view with the checkout kinds:

| Method | Path | Scope |
| - | - | - |
| `PUT` | `/api/v1/checkout-extensions/customer-account` | `WRITE_CUSTOMER_ACCOUNT_EXTENSIONS` |
| `GET` | `/api/v1/checkout-extensions/customer-account` | `WRITE_CUSTOMER_ACCOUNT_EXTENSIONS` |
| `DELETE` | `/api/v1/checkout-extensions/customer-account` | `WRITE_CUSTOMER_ACCOUNT_EXTENSIONS` |

```bash theme={null}
curl -X PUT https://api.salesive.com/api/v1/checkout-extensions/customer-account \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://loyalty.example.com/salesive/account",
    "title": "Loyalty points",
    "icon": "https://cdn.example.com/loyalty.png"
  }'
```

| Field | Type | Notes |
| - | - | - |
| `url` | string | Required. Public `https` page the theme embeds. At most 2,048 characters |
| `title` | string | Required. The label the shopper sees above the block. At most 60 characters |
| `icon` | string | Optional. Public `https` image. At most 2,048 characters. When absent, the Store API falls back to your app's icon |
| `active` | boolean | Optional. `false` registers the block switched off |

* The row is bound to the installation behind the token, never to anything in
  the request. A second `PUT` replaces the registration.
* `GET` returns `404` when nothing is registered. `DELETE` removes the row.
* Uninstalling the app switches every extension kind off, this one included.
* The URL is checked with the webhook URL guard: `https` only, no credentials,
  and no loopback or private address. The platform never calls the URL; the
  shopper's browser loads it in an iframe. For local development, use a public
  https tunnel.
* The release snapshot records the block's `title` and `icon`. Publishing does
  not rewrite the live row; the Store API reads the `AppCheckoutExtension` row.

### The merchant switch

A merchant can turn the block off without uninstalling the app:

```http theme={null}
PATCH /api/v1/apps/installed/:id/checkout-extensions/customer-account
{ "active": false }
```

Only the `active` flag can change from the merchant side. The URL stays the one
your app registered. `GET /api/v1/apps/installed/:id/checkout-extensions` lists
every extension the installation registered on that store.

## How the theme gets your block

The Store API serves the blocks to the theme:

```http theme={null}
GET /api/v1/account/extensions
```

The shopper's normal session is required. An anonymous request is `401`, and a
guest (ghost) account is refused with `login_required`. The store comes from the
storefront request, as with the other storefront routes.

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "Account extensions retrieved",
  "data": {
    "extensions": [
      {
        "id": "665f...",
        "appName": "Loyalty App",
        "title": "Loyalty points",
        "icon": "https://cdn.example.com/loyalty.png",
        "url": "https://loyalty.example.com/salesive/account?salesive_shop=...&salesive_customer=...&salesive_exp=...&salesive_signature=..."
      }
    ]
  }
}
```

| Field | Meaning |
| - | - |
| `id` | The extension row id |
| `appName` | Your app's name |
| `title` | The title you registered |
| `icon` | Your icon, or the app icon when you registered none |
| `url` | Your registered page with the signed query parameters added |

`extensions` is `[]` when the store has no active block, when every installation
is uninstalled or has lost the scope, or when the app is switched off or has no
signing secret. The endpoint returns no app secret and no shopper name, email,
phone or address. The response is `Cache-Control: private, no-store` because
every URL is minted for one shopper.

The block metadata is cached for **60 seconds** per store. A new or changed
registration can take up to 60 seconds to appear, and a block that was switched
off can linger for the same window. The signed URL is not cached: it is minted
on every request, because it names one shopper.

A store shows at most **10** blocks, oldest registration first.

## The signed URL

The URL is your registered page with four query parameters added. Existing query
parameters on your URL are preserved.

| Parameter | Value |
| - | - |
| `salesive_shop` | The store id |
| `salesive_customer` | The signed in shopper's user id |
| `salesive_exp` | Unix seconds, 300 seconds after minting |
| `salesive_signature` | base64url HMAC-SHA256 of `<salesive_exp>.<salesive_shop>.<salesive_customer>` |

The key is your app's webhook signing secret for the installation's environment:
the development secret for a development install when your app has one,
otherwise the production secret. The signature covers the expiry, the store and
the shopper, so changing any of them invalidates it.

> [!NOTE]
>
> `salesive_signature` is base64url (URL safe, no padding), not the standard
> base64 used in the `X-Salesive-Signature` header. Use your language's URL safe
> encoder, or convert `+` to `-` and `/` to `_` and drop the padding.

## Verify before you act

The URL is a bearer credential for its lifetime. Anyone who has it can use it
until it expires, so do not log it, send it to another service, or put it in a
page your shopper can copy. Treat the query parameters as untrusted until the
signature checks out.

A page that honours the contract does this on **every** request, including a
reload or a second visit within the window:

1. Read `salesive_shop`, `salesive_customer`, `salesive_exp` and
   `salesive_signature` from the URL. Reject the request when any is missing.
2. Reject the request when `salesive_exp` is in the past, or more than 300
   seconds ahead of your clock. The URL is minted with a 300 second lifetime, so
   a fresh one is always inside that window.
3. Recompute the HMAC over the three values after URL decoding, using your
   webhook signing secret. Compare it with `salesive_signature` in constant
   time.
4. Only after the signature matches, treat `salesive_customer` as the shopper
   and `salesive_shop` as the store.
5. Never take a customer id from another query parameter, a cookie, a header or
   local storage. An unsigned value is not proof of anything, and a valid
   signature for one shopper must not be reused for another.

If verification fails, show a plain error and do nothing else. Do not fall back
to an unsigned mode.

```js Node.js theme={null}
import crypto from "crypto";

const SIGNING_SECRET = process.env.SALESIVE_WEBHOOK_SECRET; // whsec_...
const MAX_AGE_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);
};

// Returns the shopper and store the URL speaks for, or null.
export function verifyAccountExtension(requestUrl) {
  const params = new URL(requestUrl).searchParams;
  const shop = params.get("salesive_shop") || "";
  const customer = params.get("salesive_customer") || "";
  const expText = params.get("salesive_exp") || "";
  const signature = params.get("salesive_signature") || "";
  const exp = Number(expText);

  if (!shop || !customer || !Number.isInteger(exp) || !signature) return null;

  const now = Math.floor(Date.now() / 1000);
  if (exp <= now || exp - now > MAX_AGE_SECONDS) return null;

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${expText}.${shop}.${customer}`)
    .digest("base64url");

  if (!safeEqual(signature, expected)) return null;
  return { shopId: shop, customerId: customer };
}
```

The `customerId` is the platform user id of the signed in shopper. It is stable
for that shopper on that store. It is not a Customer record id and carries no
name, email, phone or address. If your page needs more, ask the shopper in your
own page or use what your app already stored for that id.

## What your page must do

* Serve over `https`. The URL is embedded in an iframe on the storefront.
* Verify the signature on every request, as above.
* Work in an iframe, including a narrow mobile width. The theme controls the
  frame; your page owns its contents.
* Not depend on `document.referrer` or on the shopper's storefront cookies.
  Third party cookies may be blocked in an iframe, and the theme opens your page
  with no referrer.
* Not assume the URL is single use. The same URL can be opened more than once
  until it expires.
* Keep the block to one purpose. One installation has one customer account
  block, and a second registration replaces it.
* Return an error page, not a redirect to the storefront login, when the
  signature is missing or invalid.

## What other themes must do

The default theme is the first implementation. Any other theme opts in the same
way:

1. Call `GET /api/v1/account/extensions` on the account page with the shopper's
   normal session, using the same client the theme already uses for `/user`.
2. Read `data.extensions`.
3. Render nothing when the list is empty. No heading, no empty state.
4. Render each item as a card with `title`, `appName` and `icon`, and open
   `url` in an iframe when the shopper chooses it.

The iframe must send no referrer:

```jsx theme={null}
<iframe src={extension.url} title={extension.title} referrerPolicy="no-referrer" />
```

The response is private to one shopper. Do not cache it in a shared cache, and
do not render it on a page that is not the signed in shopper's own account area.

The default theme's implementation is in `src/pages/account/account.jsx` (the
`account-extensions` query, the "From your apps" card and the extension
dialog). It also sandboxes the iframe with
`sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`.
Start from a sandbox that is no wider than your block needs.

## Not in this surface

Only the account page is covered. Order status page blocks, more than one block
per app per store, and theme editor placement are not part of this surface yet.
The block always renders below the account summary in the default theme.

## Next steps

> [card] [Checkout extensions](https://docs.salesive.com/apps-api/checkout-extensions.md)
>
> The registration shape this surface shares, with carrier rates and cart validation.

> [card] [Storefront extensions](https://docs.salesive.com/apps/storefront-extensions.md)
>
> Scripts, embeds, blocks and the app proxy, the other ways an app reaches a store.

> [card] [Webhooks](https://docs.salesive.com/apps-api/webhooks.md)
>
> Where the signing secret comes from and how signature v2 works.

> [card] [Scopes & permissions](https://docs.salesive.com/apps/scopes-permissions.md)
>
> What `WRITE_CUSTOMER_ACCOUNT_EXTENSIONS` grants and how the merchant sees it.


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