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

# Customer session tokens

A customer session token authenticates Headless API calls made from a shopper's
own device: storefront JavaScript, a mobile app, a headless frontend. It is a
credential for a single customer on a single site. You generate and sign it
yourself, with no LoyaltyLion API call involved, and it authorizes only that
customer's own self-serve actions, which is what makes it safe to hand to a
client: even from devtools, it only lets the shopper act as themselves. That
means you can call the Headless API directly from the client, instead of
proxying calls through your backend first.

## Generating a token

Session tokens are signed with your site's **SDK token**, which comes as a
pair: a **Key ID** and a **secret**. Find both in LoyaltyLion under **Settings
→ General**, in the "SDK secret token" section, where the secret is labelled
"Token" (not the site "Token and secret" section above it, which is a different
credential and can't sign session tokens). On Shopify, the pair is also
published to your shop as metafields: `loyaltylion.secret_id` (Key ID) and
`loyaltylion.secret_key` (secret).

<Warning>
  Treat the secret like a password: anyone holding it can mint a session token
  for any of your customers. It stays on your server or in your theme; only the
  finished session token goes to the client.
</Warning>

A session token is a base64-encoded JSON payload, a dot, and the hex
HMAC-SHA256 signature of that encoded payload:

```
base64url(payload) + "." + hex(hmac_sha256(base64url(payload), secret))
```

The comments in each recipe explain how to fill in every field:

<CodeGroup>
  ```js JavaScript theme={null}
  import crypto from 'node:crypto'

  function createSessionToken({
    keyId,
    secret,
    siteId,
    customerId,
    email,
    scopes,
    lifetimeSeconds,
  }) {
    const issuedAt = Math.floor(Date.now() / 1000)

    const payload = {
      // format version, always 2
      v: 2,
      // your Key ID, e.g. '4821'
      key_id: keyId,
      // your LoyaltyLion site ID, e.g. 12345
      site_id: siteId,
      // their ID in your store (= merchant_id), always a string
      customer_id: String(customerId),
      // their email address
      email,
      // when the token was issued: a unix timestamp, in seconds
      iat: issuedAt,
      // when it expires: a unix timestamp, in seconds
      exp: issuedAt + lifetimeSeconds,
      // ['read'], ['write'] or both (write does not include read)
      scopes,
    }

    const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')

    // sign with your SDK token secret
    const signature = crypto
      .createHmac('sha256', secret)
      .update(encoded)
      .digest('hex')

    return `${encoded}.${signature}`
  }

  // e.g. a 15-minute read+write token:
  const token = createSessionToken({
    keyId: process.env.LOYALTYLION_SDK_TOKEN_KEY_ID,
    secret: process.env.LOYALTYLION_SDK_TOKEN_SECRET,
    siteId: 12345,
    customerId: '6072819304',
    email: 'shopper@example.com',
    scopes: ['read', 'write'],
    lifetimeSeconds: 900,
  })
  ```

  ```ruby Ruby theme={null}
  require 'base64'
  require 'json'
  require 'openssl'

  def create_session_token(key_id:, secret:, site_id:, customer_id:, email:, scopes:, lifetime_seconds:)
    issued_at = Time.now.to_i

    payload = JSON.generate({
      # format version, always 2
      v: 2,
      # your Key ID, e.g. '4821'
      key_id: key_id,
      # your LoyaltyLion site ID, e.g. 12345
      site_id: site_id,
      # their ID in your store (= merchant_id), always a string
      customer_id: customer_id.to_s,
      # their email address
      email: email,
      # when the token was issued: a unix timestamp, in seconds
      iat: issued_at,
      # when it expires: a unix timestamp, in seconds
      exp: issued_at + lifetime_seconds,
      # ['read'], ['write'] or both (write does not include read)
      scopes: scopes,
    })

    encoded = Base64.urlsafe_encode64(payload, padding: false)
    # sign with your SDK token secret
    signature = OpenSSL::HMAC.hexdigest('SHA256', secret, encoded)

    "#{encoded}.#{signature}"
  end

  # e.g. a 15-minute read+write token:
  token = create_session_token(
    key_id: ENV['LOYALTYLION_SDK_TOKEN_KEY_ID'],
    secret: ENV['LOYALTYLION_SDK_TOKEN_SECRET'],
    site_id: 12345,
    customer_id: '6072819304',
    email: 'shopper@example.com',
    scopes: ['read', 'write'],
    lifetime_seconds: 900,
  )
  ```

  ```php PHP theme={null}
  function create_session_token($key_id, $secret, $site_id, $customer_id, $email, $scopes, $lifetime_seconds) {
    $issued_at = time();

    $payload = json_encode([
      // format version, always 2
      'v' => 2,
      // your Key ID, e.g. '4821'
      'key_id' => $key_id,
      // your LoyaltyLion site ID, e.g. 12345
      'site_id' => $site_id,
      // their ID in your store (= merchant_id), always a string
      'customer_id' => (string) $customer_id,
      // their email address
      'email' => $email,
      // when the token was issued: a unix timestamp, in seconds
      'iat' => $issued_at,
      // when it expires: a unix timestamp, in seconds
      'exp' => $issued_at + $lifetime_seconds,
      // ['read'], ['write'] or both (write does not include read)
      'scopes' => $scopes,
    ]);

    $encoded = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
    // sign with your SDK token secret
    $signature = hash_hmac('sha256', $encoded, $secret);

    return $encoded . '.' . $signature;
  }

  // e.g. a 15-minute read+write token:
  $token = create_session_token(getenv('LOYALTYLION_SDK_TOKEN_KEY_ID'), getenv('LOYALTYLION_SDK_TOKEN_SECRET'), 12345, '6072819304', 'shopper@example.com', ['read', 'write'], 900);
  ```
</CodeGroup>

The JSON formatting is yours: we verify the exact encoded string you sign, so
key order and whitespace don't matter, either base64 alphabet works (padded or
unpadded), and the hex signature can be upper or lower case.

### On Shopify with Liquid, no backend needed

On a regular Shopify storefront, Liquid's rendering step is the server-side
step: it reads the SDK token from the shop metafields and emits only the
finished session token. Add this to your theme layout or loyalty template,
replacing `123456` with your LoyaltyLion site ID:

```liquid theme={null}
{% if customer != nil and customer.has_account %}
  {%- assign ll_site_id = 123456 -%}
  {%- capture ll_key_id -%}{{ shop.metafields.loyaltylion.secret_id }}{%- endcapture -%}
  {%- capture ll_secret -%}{{ shop.metafields.loyaltylion.secret_key }}{%- endcapture -%}
  {%- assign ll_iat = 'now' | date: '%s' -%}
  {%- assign ll_exp = ll_iat | plus: 1209600 -%}
  {%- capture ll_payload -%}{"v":2,"key_id":"{{ ll_key_id }}","site_id":{{ ll_site_id }},"customer_id":"{{ customer.id }}","email":{{ customer.email | json }},"iat":{{ ll_iat }},"exp":{{ ll_exp }},"scopes":["read","write"]}{%- endcapture -%}
  {%- assign ll_encoded = ll_payload | base64_url_safe_encode -%}
  {%- assign ll_signature = ll_encoded | hmac_sha256: ll_secret -%}

  <script>
    window.loyaltylionSessionToken = "{{ ll_encoded }}.{{ ll_signature }}"
  </script>
{% endif %}
```

Three details that matter here:

* **Keep the whitespace control (`{%-` / `-%}`) exactly as shown**: a newline
  captured into the secret corrupts it.
* **`customer.id` is quoted** because `customer_id` must be a string, and
  **`| json` on the email** escapes anything that would break the JSON.
* **`1209600` is the 14-day Shopify cap** (see [Lifetime](#lifetime)); a
  shorter lifetime risks logging shoppers out on cached pages.

Then use `window.loyaltylionSessionToken` from your storefront JavaScript.

<Note>
  If your store runs the [LoyaltyLion JS SDK](/sdk/overview), your storefront
  JavaScript can use the same token for its own Headless API calls.
</Note>

### In a mobile app

<Warning>
  Never embed the SDK token secret in an app build. Anything in the binary can
  be extracted, and anyone holding the secret can mint a session token for any
  of your customers.
</Warning>

Have your backend authenticate the shopper and return a freshly minted session
token on every launch and every login, and again on
[`token_expired`](#errors). Don't bake a token into a build or store one across
sessions: it names one customer and stops working within days.

## The payload

```json theme={null}
{
  "v": 2,
  "key_id": "4821",
  "site_id": 12345,
  "customer_id": "6072819304",
  "email": "shopper@example.com",
  "iat": 1756200000,
  "exp": 1757409600,
  "scopes": ["read", "write"]
}
```

| Field         | Type             | Description                                                                                   |
| ------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `v`           | number           | Format version, always `2`.                                                                   |
| `key_id`      | string           | Your Key ID. A bare number is also accepted and treated as its decimal string.                |
| `site_id`     | number           | Your LoyaltyLion site ID. Must match the request path.                                        |
| `customer_id` | string           | The customer's ID in your store, the same `merchant_id` you use elsewhere. Always a string.   |
| `email`       | string           | The customer's email address. Must be valid; Initialize Session creates the customer with it. |
| `iat`         | number           | When the token was issued, in seconds since the Unix epoch.                                   |
| `exp`         | number           | When the token stops working, in seconds since the Unix epoch. See [Lifetime](#lifetime).     |
| `scopes`      | array of strings | At least one of `read`, `write`. See [Scopes](#scopes).                                       |

Every field is required and unknown fields are rejected, so a typo fails the
token the first time you test, not months later.

## Scopes

There are two scopes, and they are independent: `write` does not include
`read`, so a token that both reads loyalty state and performs actions must list
both.

| Scope   | Authorizes                                                                                                                                                          |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read`  | Reading the customer's own loyalty state and your site configuration.                                                                                               |
| `write` | The supported self-serve actions: enrolling, setting a birthday, subscribing to email marketing, redeeming and refunding rewards, and completing interactive rules. |

Grant only the scopes the client needs: a points-balance widget should be
issued `["read"]`, so a copied token can't redeem.

### Which endpoints accept a session token

| Endpoint                                                                                                                                                    | Scope   |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| **Get Configuration**: `GET /{site_id}/configuration`                                                                                                       | `read`  |
| **Get Customer**: `GET /{site_id}/customers/{merchant_id}`                                                                                                  | `read`  |
| **Initialize Session**: `POST /{site_id}/customers/{merchant_id}/sessions`                                                                                  | `write` |
| **Enroll Customer**: `POST /{site_id}/customers/{merchant_id}/enroll`                                                                                       | `write` |
| **Set Birthday**: `POST /{site_id}/customers/{merchant_id}/birthday`                                                                                        | `write` |
| **Subscribe to Email Marketing**: `POST /{site_id}/customers/{merchant_id}/email_marketing/subscribe`                                                       | `write` |
| **Redeem a reward**: `POST /{site_id}/rewards/{type}/redeem`, for all nine reward types                                                                     | `write` |
| **Refund a product-to-cart reward**: `POST /{site_id}/rewards/product_cart/refund`                                                                          | `write` |
| **Complete a rule**: `POST /{site_id}/rules/{type}/complete`, for `clickthrough`, `facebook_like`, `instagram_follow`, `tiktok_follow` and `twitter_follow` | `write` |

Initialize Session is the one endpoint that creates the customer when
LoyaltyLion doesn't hold them yet, so a storefront can run entirely on session
tokens. The customer is created from the token's **signed claims**, never the
request body. The body's `customer.email` must still match the signed email
(case-insensitively) or the request fails with `email_mismatch`.

Everything else needs an API key and stays server-side: completing a custom
rule, the referrals endpoints, and every Admin API (`/v2/*`) endpoint. A
session token sent to any of those is rejected with a `401` that says `Session
tokens are not supported on this route`. On **Redeem a custom reward**, the
`fulfill_immediately` and `usage` options are merchant-side controls and are
rejected with a `403` under a session token.

## Moving a browser call off the Admin API

A session token can't replace an Admin API (`/v2/*`) call; those stay
API-key-only. If your storefront calls one directly today, there are two routes
off it.

**The Headless API already has it.** One Get Customer call covers most of what a
storefront reads per shopper, so several `/v2/*` calls usually collapse into it:

| Currently calling                                   | Read instead from `GET /{site_id}/customers/{merchant_id}` |
| --------------------------------------------------- | ---------------------------------------------------------- |
| `GET /v2/customers`, paged to find one shopper      | the response itself                                        |
| `GET /v2/customers/{merchant_id}/transactions`      | `history` (but see below)                                  |
| `GET /v2/customers/{merchant_id}/available_rewards` | `available_rewards`                                        |
| points balances                                     | `points_approved`, `points_pending`, `points_spent`        |
| tier                                                | `tier_membership`                                          |

There is no per-customer read on the Admin API, so a storefront showing one
shopper their points is paging the whole customer list to find them. Headless
Get Customer replaces that outright: the session token already names the
customer in its signed claims, so there is no list to search and no way to ask
for anyone else.

`history` is not the transactions list under another name. A single history
action can cover several transactions: points added and later voided are one
entry whose state changes, not two rows. If your page renders a transaction
ledger, check it against `history` before switching.

Writes have equivalents too: claiming a reward is
`POST /{site_id}/rewards/{type}/redeem` under `write` scope.

**Nothing equivalent exists.** Then the call belongs on your server with an API
key, and your storefront asks your server rather than us. `POST /v2/activities`
is the usual case, and deliberately so: an activity a shopper's own browser can
submit is an activity a shopper can fabricate.

## Lifetime

`exp` is required; there is no default lifetime. We clamp rather than reject:
a token stops working at its own `exp` or `iat` plus the cap, whichever comes
first.

| Site platform   | Cap     |
| --------------- | ------- |
| Shopify         | 14 days |
| Everything else | 1 day   |

The cap follows the site the token is for, not where it is minted: a
backend-minted token for a Shopify site still gets the 14-day cap.

* `exp` must be later than `iat`.
* `iat` more than 5 minutes in the future invalidates the token. If one
  server's tokens are rejected while others work, check its clock.

Issue the shortest lifetime that fits: minutes from a backend minting per
session; the full 14 days from a Shopify theme, because Shopify serves cached
pages (including any token rendered into them) for far longer than a day.

## Using a token

Send the token as a bearer token on any endpoint that accepts one:

```
GET https://api.loyaltylion.com/headless/2025-06/12345/customers/6072819304?channel=web
Authorization: Bearer <session token>
```

From a browser, pass `channel` (and `language`, if you use it) as query
parameters, as here: the CORS preflight doesn't allow the equivalent
`X-LoyaltyLion-*` headers on a cross-origin request.

The token has to agree with the request: the `site_id` in the path must be the
one it was signed with, and any customer the request names (`merchant_id` in
the path, or `customer_merchant_id` in the body) must be its customer.

Requests are rate limited per customer, so one shopper can't exhaust your
site's budget, and CORS preflights are answered with
`Access-Control-Allow-Origin: *`, so a browser can call the API directly.

## Errors

Rejections are almost always a `401`. A `403` means something narrower: the
token is fine, but doesn't authorize this particular request; see
[the `403`s](#403s).

Two rejections carry a machine-readable `code` your client should branch on:

<CodeGroup>
  ```json token_expired (401) theme={null}
  {
    "error": {
      "code": "token_expired",
      "message": "Session token has expired. Fetch a fresh session token from your backend and retry the request"
    }
  }
  ```

  ```json insufficient_scope (403) theme={null}
  {
    "error": {
      "code": "insufficient_scope",
      "message": "Requires scope [write] but session token only has scopes [read]"
    }
  }
  ```
</CodeGroup>

Handle `token_expired` by getting a fresh token and retrying once. Don't retry
on `insufficient_scope`: the fix is in the code that issues the token.

Everything else is a `401` with no code:

```json theme={null}
{
  "error": {
    "message": "Session token is invalid. Please check the format is correct: https://developers.loyaltylion.com/headless-api/session-tokens [INVALID_SIGNATURE]"
  }
}
```

The tag in brackets says which check failed:

| Tag                      | Meaning                                                                                                                                                                        |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MALFORMED`              | The payload half isn't decodable JSON. A bearer without the `<payload>.<hex>` shape at all isn't treated as a session token and fails as a generic invalid credential instead. |
| `INVALID_CLAIMS`         | The payload is missing a field, has an unknown one, or `exp` is not after `iat`.                                                                                               |
| `SITE_MISMATCH`          | The token's `site_id` isn't the site in the request path.                                                                                                                      |
| `UNKNOWN_KEY`            | The `key_id` names no active SDK token for this site, usually a rotated or mistyped Key ID.                                                                                    |
| `RESERVED_KEY_NAMESPACE` | The `key_id` starts with `lion_`, which is reserved for keys we hold.                                                                                                          |
| `INVALID_SIGNATURE`      | The signature doesn't match the payload under that key.                                                                                                                        |
| `ISSUED_IN_FUTURE`       | `iat` is further ahead than clock skew explains. Check the issuing server's clock.                                                                                             |

The signature is checked before the timestamps, so a token that is both
mis-signed and expired reports `INVALID_SIGNATURE`, not `token_expired`.

Some `401`s carry no tag. The two worth knowing: a site in the request path
that doesn't exist (deliberately indistinguishable from an invalid token, so
site IDs can't be enumerated), and a verified token naming a customer
LoyaltyLion doesn't hold. Only Initialize Session creates customers, so
everywhere else the signed `customer_id` must be a customer we already hold.
A token sent to a route that doesn't accept one is also an untagged `401`; see
[which endpoints accept a session
token](#which-endpoints-accept-a-session-token).

### 403s

A `403` always means the token verified. There are four:

* `insufficient_scope`, above.
* The request names a different customer than the token was signed for.
* Redeem a custom reward was called with `fulfill_immediately` or `usage`.
* The site has been uninstalled, or its LoyaltyLion subscription is inactive.
