npm package: @neostore/cinto
Description: Generate a Add to Wallet button
Author: Cyril DURAND - CTO
Latest version: 1.0.93

# Cinto SDK — Add to Wallet Button for Apple Wallet & Google Wallet

**Cinto** is a JavaScript / TypeScript SDK that lets you add an **Add to Wallet button** to any website in minutes. It automatically shows the correct button for Apple Wallet (iOS) or Google Wallet (Android) based on the visitor's device, and opens a pass viewer modal on desktop.

Built and maintained by [The Wallet Crew](https://www.thewalletcrew.io) — a **Wallet-as-a-Service** platform for digital passes, loyalty cards, membership cards, event tickets, and more.

**→ Documentation:** https://docs.thewalletcrew.io/enroll/on-your-website  
**→ npm:** https://www.npmjs.com/package/@neostore/cinto  
**→ Website:** https://www.thewalletcrew.io

---

## What is Cinto?

Cinto is the front-end SDK for [The Wallet Crew](https://www.thewalletcrew.io). It solves a common problem: **how do you let your users save a digital pass to their phone wallet without building separate Apple and Google integrations?**

- Drop in a `<script>` tag or install via npm — no back-end changes required for basic usage
- One `<div>` renders the right platform button automatically
- Works with loyalty cards, event tickets, membership cards, boarding passes, coupons, gift cards, and any other Apple / Google Wallet pass type
- Supports real-time pass updates via WebSocket (`listenToPassEvents`)
- Fully typed TypeScript API

---

## Who is this for?

| Use case | Example |
|---|---|
| E-commerce / retail | Add loyalty card or gift card to wallet at checkout |
| Events & ticketing | Let attendees save their ticket to Apple or Google Wallet |
| Hospitality | Hotel key cards, membership passes |
| Healthcare | Appointment cards, insurance cards |
| Transport | Transit passes, boarding passes |
| Any SaaS with digital passes | Embed the button in your customer portal |

---

## How it works

The SDK detects the user's device and renders the right button automatically:

| Platform  | Detected when         | Behaviour on click                            |
|-----------|-----------------------|-----------------------------------------------|
| `apple`   | iOS / iPadOS device   | Downloads the `.pkpass` file directly         |
| `google`  | Android device        | Opens the Google Pay "Save to wallet" flow    |
| `desktop` | Any non-mobile device | Opens a modal overlay with the pass page      |

---

## Quick start

### CDN (no build step)

Add the snippet below to any page. Replace `YOUR_TENANT_ID` with your tenant identifier and add a container element with `data-neostore-addToWalletButton` and `data-neostore-passId`:

```html
<script type="text/javascript">
    (function (n, e, o) {
        var s = n.createElement("script");
        s.src = "https://sdk.neostore.cloud/scripts/" + e + "/cinto@1";
        s.async = 1;
        s.onload = function () {
            neostore.cinto.initialize(e, o);
        };
        n.body.appendChild(s);
    })(document, "YOUR_TENANT_ID", {});
</script>

<div data-neostore-addToWalletButton data-neostore-passId="KlnqcxVLA9pS4ol5"></div>
```

`initialize()` scans the entire page for `[data-neostore-addToWalletButton]` elements and renders a button in each one. It is safe to call before or after `DOMContentLoaded`.

The third argument accepts any option from the [Options](#options) section. Example — force French:

```html
<script type="text/javascript">
    (function (n, e, o) {
        var s = n.createElement("script");
        s.src = "https://sdk.neostore.cloud/scripts/" + e + "/cinto@1";
        s.async = 1;
        s.onload = function () {
            neostore.cinto.initialize(e, o);
        };
        n.body.appendChild(s);
    })(document, "YOUR_TENANT_ID", { language: "fr" });
</script>

<div data-neostore-addToWalletButton data-neostore-passId="KlnqcxVLA9pS4ol5"></div>
```

### npm

```sh
npm install @neostore/cinto
```

```ts
import { AddToWalletButton } from "@neostore/cinto";

const btn = new AddToWalletButton("YOUR_TENANT_ID", {
    language: "fr",
    passId: "KlnqcxVLA9pS4ol5",
});
btn.render(document.getElementById("btn"));
```

---

## Options

Options can be set via the constructor / `initialize()` call, or as `data-neostore-*` attributes on the container element. Per-element data attributes always take precedence.

```ts
interface Options {
    /**
     * Your tenant identifier. Required.
     */
    tenantId: string;

    /**
     * Pass ID to display.
     * Can also be an async function that resolves to the pass ID.
     * Required unless externalIdentifiers is provided.
     */
    passId: string | (() => Promise<string>);

    /**
     * API environment base URL.
     * @default "https://app.neostore.cloud"
     */
    environment?: string;

    /**
     * Force a display language (ISO 639 code, e.g. "fr", "de", "es-MX").
     * When omitted the browser language is used, falling back to "en".
     */
    language?: string;

    /**
     * Force a platform instead of auto-detecting from the user-agent.
     * @values "apple" | "google" | "desktop"
     */
    platform?: "apple" | "google" | "desktop";

    /**
     * Pass layout name used to build the desktop pass page URL.
     * When omitted and a single passId is used, the SDK resolves the URL
     * through /api/{tenantId}/passes/{passId}/view.
     * When multiple comma-separated pass IDs are provided, defaults to "tickets".
     */
    passLayoutName?: string;

    /**
     * External identifiers used to resolve the passId server-side.
     * Use this when the pass ID is not known up-front.
     * Requires passType to be set as well.
     */
    externalIdentifiers?: Record<string, {
        value: string;
        hmac?: string;    // HMAC-SHA256 signature generated by your back-end
        secret?: string;  // Alternative signing method
    }>;

    /**
     * Pass type used when resolving a passId from externalIdentifiers.
     */
    passType?: string;

    /**
     * Analytics source metadata attached to download/view events.
     * utm_source, utm_campaign, and utm_medium are automatically picked up
     * from the page URL and merged with the values below.
     */
    source?: {
        tags?: string[];   // Enriched by utm_source and utm_campaign from the URL
        medium?: string;   // Falls back to utm_medium from the URL
        origin?: string;   // Defaults to the current page URL (without query string)
    };

    /**
     * Called when the user clicks the button, before the platform action runs.
     */
    onClick?: (e: MouseEvent, data: { options: Partial<Options>; platform: Platform }) => void;

    /**
     * Called when an error occurs (e.g. pass not found, network failure).
     */
    onError?: (error: string) => void;

    /**
     * Enable real-time pass update events via WebSocket.
     * @default false
     */
    listenToPassEvents?: boolean;

    /**
     * Called each time a pass event is received over the WebSocket.
     * See the Pass Events section for the full PassEventArgs interface.
     */
    onPassEvent?: (args: PassEventArgs) => void;
}
```

### Data attributes reference

Every option can also be set directly on the container element:

| Data attribute | Corresponding option | Notes |
|---|---|---|
| `data-neostore-passId` | `passId` | |
| `data-neostore-passType` | `passType` | |
| `data-neostore-platform` | `platform` | `"apple"`, `"google"` or `"desktop"` |
| `data-neostore-language` | `language` | ISO 639 code |
| `data-neostore-listenToPassEvents` | `listenToPassEvents` | `"true"` or `"false"` |
| `data-neostore-onPassEvent` | — | Name of a `window.*` function to call on pass events |
| `data-neostore-src` | `source.tags` | Comma-separated list of tags |
| `data-neostore-src-tags` | `source.tags` | Comma-separated list of tags |
| `data-neostore-src-medium` | `source.medium` | |
| `data-neostore-src-origin` | `source.origin` | |
| `data-neostore-externalIdentifiers-{key}-value` | `externalIdentifiers[key].value` | |
| `data-neostore-externalIdentifiers-{key}-hmac` | `externalIdentifiers[key].hmac` | |
| `data-neostore-externalIdentifiers-{key}-secret` | `externalIdentifiers[key].secret` | |

---

## Platform detection

Platform is detected from the user-agent at render time:

- **Apple device (iPhone / iPad)** → `apple` button — downloads the `.pkpass` directly
- **Android device** → `google` button — opens the Google Pay "Save to wallet" page
- **Everything else (desktop, tablet, unknown)** → `desktop` button — opens a modal overlay with the pass page

Override the detection for testing: `data-neostore-platform="apple"` or the `platform` option.

---

## Language detection

The browser's language preference list is used automatically. The `language` option (or `data-neostore-language`) pins a specific value. If no match is found the button defaults to `en`.

Supported languages per platform:

**Apple Wallet button:**
`ar`, `az`, `bg`, `cn`, `cz`, `de`, `dk`, `ee`, `en`, `es`, `es-CA`, `es-MX`, `fi`, `fr`, `fr-CA`, `gr`, `hb`, `hk`, `hr`, `hu`, `id`, `in`, `it`, `jp`, `kr`, `lt`, `lv`, `mt`, `my`, `nl`, `no`, `ph`, `pl`, `pt`, `pt-BR`, `ro`, `ru`, `se`, `si`, `sk`, `th`, `tr`, `tw-TC`, `ua`, `vn`

**Google Pay button:**
`ar`, `az`, `bg`, `br`, `bs`, `by`, `ca`, `cz`, `de`, `dk`, `en`, `en-AU`, `en-CA`, `en-IN`, `en-SG`, `en-US`, `en-ZA`, `es`, `es-ES`, `es-US`, `et`, `fr-CA`, `fr-FR`, `gr`, `he`, `hr`, `hu`, `hy`, `id`, `is`, `it`, `jp`, `ka`, `kk`, `ky`, `lt`, `lv`, `mk`, `my`, `nl`, `no`, `pl`, `pt`, `ro`, `ru`, `se`, `sk`, `sq`, `sr`, `th`, `tr`, `uk`, `uz`, `vi`, `zh`, `zh-HK`, `zh-TW`

**Desktop button:**
`ar`, `az`, `bg`, `cs`, `da`, `de`, `el`, `en`, `es`, `fr`, `it`, `ja`, `nl`, `pl`, `pt`, `ru`, `zh`

---

## Customisation

The rendered HTML structure is:

```
<div data-neostore-addToWalletButton>   ← your container element
  <a class="neostore-link">             ← generated by the SDK
    <img class="neostore-img neostore-img-{platform}">
  </a>
</div>
```

CSS classes you can target:

| Class | Element | Notes |
|---|---|---|
| `.neostore-link` | `<a>` | The clickable button wrapper |
| `.neostore-img` | `<img>` | The wallet button image |
| `.neostore-img-apple` | `<img>` | Present on Apple buttons |
| `.neostore-img-google` | `<img>` | Present on Google buttons |
| `.neostore-img-desktop` | `<img>` | Present on desktop buttons |
| `.neostore-loading` | `<a>` | Added while the click action is in progress |
| `.neostore-img-loading` | `<img>` | Present until the image has finished loading |

The Apple and Google images comply with their respective official design guidelines.

---

## Usage examples

### Multiple buttons on the same page

`initialize()` automatically processes all `[data-neostore-addToWalletButton]` elements. Each element can carry its own `data-neostore-passId`:

```html
<div data-neostore-addToWalletButton data-neostore-passId="pass1"></div>
<div data-neostore-addToWalletButton data-neostore-passId="pass2"></div>
```

### Multiple passes in one button

Pass a comma-separated list of pass IDs. Set `passLayoutName` to control the layout page (defaults to `"tickets"` when multiple IDs are detected):

```html
<div
    data-neostore-addToWalletButton
    data-neostore-passId="pass1,pass2,pass3"
></div>
```

### `passId` as an async function (npm only)

When the pass ID must be fetched at click time, provide a function instead of a string:

```ts
const btn = new AddToWalletButton("YOUR_TENANT_ID", {
    passId: async () => {
        const res = await fetch("/api/my-pass");
        return (await res.json()).passId;
    },
});
btn.render(document.getElementById("btn"));
```

### Using external identifiers

When the pass ID is not known up-front, provide external identifiers for the SDK to resolve it server-side. See the [back-end setup documentation](https://docs.thewalletcrew.io/enroll/on-your-website) for generating the HMAC on your server.

**Using data attributes:**

```html
<script type="text/javascript">
    (function (n, e, o) {
        var s = n.createElement("script");
        s.src = "https://sdk.neostore.cloud/scripts/" + e + "/cinto@1";
        s.async = 1;
        s.onload = function () {
            neostore.cinto.initialize(e, o);
        };
        n.body.appendChild(s);
    })(document, "YOUR_TENANT_ID", {});
</script>

<div
    data-neostore-addToWalletButton
    data-neostore-passType="membership"
    data-neostore-externalIdentifiers-customerId-value="SC103010"
    data-neostore-externalIdentifiers-customerId-hmac="cbbcfc5xxxxx"
></div>
```

**Using the npm component:**

```ts
import { AddToWalletButton } from "@neostore/cinto";

const btn = new AddToWalletButton("YOUR_TENANT_ID", {
    passType: "membership",
    externalIdentifiers: {
        customerId: {
            value: "SC103010",
            hmac: "cbbcfc5xxxxx", // HMAC-SHA256 signed by your back-end
        },
    },
});
btn.render(document.getElementById("btn"));
```

### Case-sensitive external identifier keys

HTML `data-*` attribute names are lowercased by the browser. To preserve uppercase letters in an identifier key, prefix the capital letter with `_`:

```html
<!-- produces the key "customerId" (capital I) -->
<div data-neostore-externalIdentifiers-customer_Id-value="SC103010"></div>
```

### Analytics / source tracking

UTM parameters in the page URL (`utm_source`, `utm_campaign`, `utm_medium`) are captured automatically. You can add extra tags via data attributes or the `source` option:

```html
<div
    data-neostore-addToWalletButton
    data-neostore-passId="pass1"
    data-neostore-src-tags="email-newsletter,welcome"
    data-neostore-src-medium="email"
></div>
```

Or via the npm component:

```ts
const btn = new AddToWalletButton("YOUR_TENANT_ID", {
    passId: "pass1",
    source: {
        tags: ["email-newsletter", "welcome"],
        medium: "email",
    },
});
```

### Desktop: render a QR code instead of the modal

On desktop the button normally opens a modal overlay. To render a QR code yourself, use `getPassPageUrl()` to retrieve the URL:

```html
<script src="https://cdn.rawgit.com/davidshimjs/qrcodejs/gh-pages/qrcode.min.js"></script>

<div id="qrcode"></div>
<script type="module">
    import { AddToWalletButton } from "@neostore/cinto";

    const button = new AddToWalletButton("YOUR_TENANT_ID", {
        passId: "KlnqcxVLA9pS4ol5",
    });
    const url = await button.getPassPageUrl();
    new QRCode(document.getElementById("qrcode"), url);
</script>
```

### React integration

```tsx
import React, { useEffect, useRef } from "react";
import { AddToWalletButton } from "@neostore/cinto";

const CintoButton: React.FC<{ tenantId: string; passId?: string }> = ({ tenantId, passId }) => {
    const ctaRef = useRef<HTMLDivElement>(null);
    const cintoRef = useRef<AddToWalletButton>();

    useEffect(() => {
        cintoRef.current = passId
            ? new AddToWalletButton(tenantId, { passId })
            : undefined;
        if (ctaRef.current) {
            cintoRef.current?.render(ctaRef.current);
        }
    }, [passId, tenantId]);

    return <div ref={ctaRef} />;
};

export default CintoButton;
```

---

## Pass Events

When `listenToPassEvents` is enabled, the SDK opens a WebSocket connection to the server and calls your handler whenever the pass is updated (e.g. points balance changed, pass invalidated). Each button manages its own isolated connection.

The connection automatically reconnects on network drops using exponential backoff (up to 20 attempts, capped at 30 s per attempt). Calling `args.disconnect()` stops reconnection permanently.

> **Note:** `listenToPassEvents` defaults to `false`.

### Option 1 — DOM custom event

```html
<div id="btn1" data-neostore-addToWalletButton data-neostore-passId="pass1" data-neostore-listenToPassEvents="true"></div>
<div id="btn2" data-neostore-addToWalletButton data-neostore-passId="pass2" data-neostore-listenToPassEvents="true"></div>

<script>
    document.getElementById("btn1").addEventListener("neostore:pass-event", (e) => {
        const args = e.detail; // PassEventArgs
        console.log("Pass updated:", args.data);
        console.log("Pass ID:", args.passId);
        console.log("At:", new Date(args.timestamp));
    });

    document.getElementById("btn2").addEventListener("neostore:pass-event", (e) => {
        console.log("Pass 2 updated:", e.detail.data);
    });
</script>
```

### Option 2 — Named window handler (data attribute)

```html
<div
    data-neostore-addToWalletButton
    data-neostore-passId="pass1"
    data-neostore-listenToPassEvents="true"
    data-neostore-onPassEvent="handlePassEvent"
></div>

<script>
    window.handlePassEvent = (args) => {
        console.log("Pass updated:", args.data);
        console.log("Pass ID:", args.passId);
    };
</script>
```

### Option 3 — `onPassEvent` callback (npm)

```ts
const button = new AddToWalletButton("YOUR_TENANT_ID", {
    passId: "pass1",
    listenToPassEvents: true,
    onPassEvent: (args) => {
        console.log("Pass updated:", args.data);
    },
});
button.render(document.getElementById("btn"));
```

### PassEventArgs

All three options receive the same `PassEventArgs` object:

| Property / Method | Type | Description |
|---|---|---|
| `args.data` | `unknown` | The event payload received from the server |
| `args.passId` | `string` | The pass ID that triggered the event |
| `args.timestamp` | `number` | Unix timestamp (ms) when the event was received |
| `args.disconnect()` | `() => void` | Close the WebSocket, clear timers, and release resources |
| `args.reconnect()` | `() => Promise<void>` | Re-open the connection after `disconnect()` |

### Example: process one event then stop

```ts
const button = new AddToWalletButton("YOUR_TENANT_ID", {
    passId: "pass1",
    listenToPassEvents: true,
    onPassEvent: (args) => {
        console.log("Received:", args.data);
        args.disconnect(); // releases the WebSocket immediately
    },
});
button.render(document.getElementById("btn"));
```

### Example: pause and resume

```ts
const button = new AddToWalletButton("YOUR_TENANT_ID", {
    passId: "pass1",
    listenToPassEvents: true,
    onPassEvent: async (args) => {
        if (args.data.type === "update-needed") {
            args.disconnect();
            await doWork();
            await args.reconnect();
        }
    },
});
button.render(document.getElementById("btn"));
```

---

## API reference

### `initialize(tenantId, options?)` — CDN

Scans the page for `[data-neostore-addToWalletButton]` elements and renders a button in each one. Safe to call at any point; waits for `DOMContentLoaded` if the DOM is not yet ready.

```js
neostore.cinto.initialize("YOUR_TENANT_ID", { language: "fr" });
```

### `AddToWalletButton` — npm

Creates a button instance for a single element.

```ts
const btn = new AddToWalletButton(tenantId, options?);
```

| Method / Getter | Returns | Description |
|---|---|---|
| `render(element)` | `Promise<void>` | Renders the button into `element` |
| `perform()` | `Promise<void>` | Programmatically triggers the button action (download / open modal) |
| `getPassPageUrl()` | `Promise<string>` | Returns the destination URL — useful for custom QR code rendering |
| `platform` | `"apple" \| "google" \| "desktop"` | Detected platform (read-only getter, synchronous) |

### `getPassId(externalIdentifiers, options)` — npm

Looks up an existing pass ID from external identifiers. Returns `null` if no pass matches.

### `getOrCreatePassId(externalIdentifiers, options)` — npm

Looks up or creates a pass from external identifiers.

### `getViewUrl(passId, options)` — npm

Returns the direct wallet URL for a pass:
- Apple → `shoebox://` deep-link
- Google → `https://pay.google.com/gp/v/save/{jwt}`

---

## FAQ

**How do I add an "Add to Apple Wallet" button on my website?**  
Install `@neostore/cinto` via npm or use the CDN snippet. Add a `<div data-neostore-addToWalletButton data-neostore-passId="...">` element. The SDK detects iOS automatically and renders the official Apple Wallet button that downloads the `.pkpass` file.

**How do I add a "Save to Google Wallet" button on my website?**  
Same setup as above — the SDK detects Android and renders the Google Pay "Save to wallet" button, which opens the Google Wallet save flow.

**Does it work without a build tool?**  
Yes. Use the CDN `<script>` snippet. No npm, no bundler required.

**Do I need separate code for Apple and Google?**  
No. One `<div>` and one SDK call handles both. Platform is detected automatically from the visitor's user-agent.

**What types of passes are supported?**  
Any pass type supported by The Wallet Crew: loyalty cards, membership cards, event tickets, coupons, gift cards, boarding passes, transit passes, hotel keys, and more.

**What languages are supported?**  
The button UI is localised in 50+ languages across Apple and Google platforms. The browser language is detected automatically. See the [Language detection](#language-detection) section for the full list.

**Can I use this with React / Vue / Angular?**  
Yes — see the [React integration](#react-integration) example. The `AddToWalletButton` class is a plain TypeScript class that wraps any `HTMLElement`.

**What happens when the user is on a desktop (not a phone)?**  
A modal overlay opens with the pass page, letting the user scan a QR code or view the pass details. Alternatively you can use `getPassPageUrl()` to render your own QR code.

**Can I listen for real-time pass updates?**  
Yes — set `listenToPassEvents: true`. The SDK opens a WebSocket and dispatches a `neostore:pass-event` custom DOM event each time the pass is updated server-side. See [Pass Events](#pass-events).

**Is the pass ID always required up-front?**  
No. You can pass an async function as `passId`, or use `externalIdentifiers` to let the SDK resolve the pass ID from your own identifiers (e.g. customer ID, order ID) with HMAC signing.

**Is this open source?**  
The SDK is MIT licensed. The back-end pass management platform is provided by [The Wallet Crew](https://www.thewalletcrew.io) and requires an account.