# Browser (@fianto/js) (/developers/sdks/js)

`@fianto/js` runs in the browser. It opens the hosted checkout page for a session your server
created, in a popup or as a full-page redirect, and reports how the popup ended. It never holds
your app secret. `<fianto-button>` is the same thing as a ready-made "Pay with Fianto" button for
pages without a build step.

## Before you start [#before-you-start]

* A checkout route on your server that answers `{ id, url }`: `Checkout()` in
  [Next.js](/developers/sdks/nextjs), `checkout()` in [Express](/developers/sdks/express) or
  [Hono](/developers/sdks/hono), or `createCheckoutHandler`.
* The page that opens checkout is served from the same origin as the session's `success_url`. The
  checkout page posts its result only to that origin.

## Steps [#steps]

**Step 1.**

### Install the package [#install-the-package]

#### pnpm

```bash
pnpm add @fianto/js
```

#### npm

```bash
npm install @fianto/js
```

#### yarn

```bash
yarn add @fianto/js
```

#### bun

```bash
bun add @fianto/js
```

Without a bundler, use the script build instead (step 5).

**Step 2.**

### Open checkout from a click [#open-checkout-from-a-click]

Call `openCheckout` synchronously inside the click handler, with no `await` or `setTimeout` before
it. It opens the popup first and only then waits for your route, so the browser's popup blocker
sees a direct response to the click.

```ts
import { fetchCheckoutSession, openCheckout } from '@fianto/js';

const MESSAGES: Record<string, string> = {
  succeeded: 'Thanks! We are confirming your payment.',
  canceled: 'Checkout canceled.',
  expired: 'That checkout link expired. Check your order status before trying again.',
  closed: 'We could not tell what happened. Check your order status before paying again.',
};

document.querySelector('#pay')?.addEventListener('click', () => {
  openCheckout({
    session: () => fetchCheckoutSession('/api/checkout', { body: { item: 'beans' } }),
    fallback: 'redirect', // the default: if the popup is blocked, send the whole page to checkout
    popup: { width: 480, height: 720 }, // the default size
  }).then(
    (result) => {
      document.querySelector('#status')!.textContent = MESSAGES[result.status] ?? '';
    },
    (error) => console.error(error),
  );
});
```

`session` is a `{ id, url }` you already have, or a function that returns a promise of one. Use
`fetchCheckoutSession(endpoint, { body })` for that function: it posts `body` as JSON with
`credentials: 'same-origin'` and rejects with a `CheckoutSessionError` (`code`, `status`,
`retryAfter`) when your route refuses, for example `payment_in_progress`. It rejects with
`network_error` when the route cannot be reached, and `session_request_failed` when it answers an
error without a `{ error: { code } }` body.

Clicking again while a checkout is open should bring its popup to the front rather than start a
second one: `focusCheckout()` does that and returns `false` when no popup is open. A second
`openCheckout()` call takes over the popup, and the first call resolves `closed` with reason
`superseded`.

**Step 3.**

### Act on the result [#act-on-the-result]

| `status`    | Meaning                                                                                       |
| ----------- | --------------------------------------------------------------------------------------------- |
| `succeeded` | The payer's transaction was confirmed on the checkout page. Not proof that the order is paid. |
| `canceled`  | The payer canceled on the checkout page.                                                      |
| `expired`   | The session expired.                                                                          |
| `closed`    | Unknown. No result reached your page. `reason` says why.                                      |

| `reason` (with `closed`) | Meaning                                                                                                                                                           |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `closed_by_payer`        | The popup was closed before checkout opened in it, or more than about 1.5 s after it opened checkout.                                                             |
| `unreachable`            | The popup was cut off within about 1.5 s of opening checkout, almost always by your page's `Cross-Origin-Opener-Policy: same-origin`. Checkout may still be open. |
| `superseded`             | A newer `openCheckout()` call took over the popup.                                                                                                                |
| `returned_from_redirect` | The page was sent to checkout and the payer came back through the browser's back/forward cache.                                                                   |

> **Fulfil from Fianto, not from the browser:**
>
> `succeeded` is what the payer's browser saw. Mark the order paid only from a verified `order.paid`
> webhook or a server-side retrieve of the order. Neither the result nor a visit to `success_url`
> proves payment.

> **closed means unknown:**
>
> `closed` tells you nothing about the payment: a payment may still be confirming. Tell the payer to
> check their order status before paying again.

**Step 4.**

### Handle popup blockers and full-page checkout [#handle-popup-blockers-and-full-page-checkout]

If the browser blocks the popup, `fallback: 'redirect'` sends the whole page to checkout and the
promise normally never settles. With `fallback: 'none'`, `openCheckout` rejects with a
`PopupBlockedError` instead. To skip the popup entirely, use `redirectToCheckout`:

```ts
import { fetchCheckoutSession, redirectToCheckout } from '@fianto/js';

document.querySelector('#pay')?.addEventListener('click', () => {
  // Sends the whole page to checkout. Resolves `closed` only if the payer comes back
  // through the back/forward cache.
  void redirectToCheckout(() => fetchCheckoutSession('/api/checkout', { body: { item: 'beans' } }));
});
```

**Step 5.**

### Or drop in `<fianto-button>` [#or-drop-in-fianto-button]

With a bundler, `import '@fianto/js/button'` registers the element. Without one, serve the file
`dist/fianto-button.global.iife.js` from the installed `@fianto/js` on your own site, at the path
your `<script>` tag uses. That build also sets `window.Fianto` to `openCheckout`,
`redirectToCheckout`, `fetchCheckoutSession` and `focusCheckout`.

```html
<script src="/fianto-button.js"></script>

<fianto-button session-endpoint="/api/checkout" data-item="beans" label="buy"></fianto-button>
<p id="status" role="status"></p>

<script>
  var button = document.querySelector('fianto-button');
  button.addEventListener('fianto:result', function (event) {
    // event.detail is the openCheckout result: { status, session_id, reason? }
    document.getElementById('status').textContent = event.detail.status;
  });
  button.addEventListener('fianto:error', function (event) {
    console.error(event.detail.code, event.detail.message); // message is for you, not the payer
  });
</script>
```

> **Loading the script from a CDN:**
>
> If you load `fianto-button.global.iife.js` from a CDN instead of your own site, pin an exact version
> in the URL and add an `integrity` (SRI) hash with `crossorigin="anonymous"`, so the browser refuses
> any other file.

On click, the button posts its `data-*` attributes as the JSON body to `session-endpoint`, so
`data-item="beans"` arrives as `{ "item": "beans" }`. Setting the element's `session` property
(a `{ id, url }` or a function returning a promise of one) overrides `session-endpoint`. With
neither, the click fires `fianto:error` with code `no_session_source`. Both events bubble and cross
shadow roots, so you can listen on any ancestor.

| Attribute          | Values                                                               | Default                                                 |
| ------------------ | -------------------------------------------------------------------- | ------------------------------------------------------- |
| `session-endpoint` | Your checkout route's URL                                            | —                                                       |
| `theme`            | `brand`, `dark`, `light`, `outline`, `auto`                          | `brand`                                                 |
| `label`            | `plain` (logo only), `pay`, `buy`, `checkout`, `subscribe`, `donate` | `pay`                                                   |
| `shape`            | `rect`, `rounded`, `pill`                                            | `rounded`                                               |
| `size`             | `static`, `fill` (full width)                                        | `static`                                                |
| `locale`           | `en`, `vi`                                                           | the browser's language if it is one of these, else `en` |
| `fallback`         | `redirect`, `none`                                                   | `redirect`                                              |
| `disabled`         | boolean                                                              | —                                                       |

An unknown value falls back to the default. Size it with the CSS variables
`--fianto-button-height` (40 to 55px, default 44px), `--fianto-button-radius`,
`--fianto-button-width` (default `auto`) and `--fianto-button-focus-ring`, set on the element.

When your route refuses, the button shows the payer a short line chosen by the error's `code`, never
your route's `message`. After a `closed` result with reason `unreachable`, it shows "Lost track of
the checkout window. Check your order status before trying again." and ignores clicks for 6 s. A
click while a checkout is open brings its popup to the front.

**Step 6.**

### Let the popup talk to your page [#let-the-popup-talk-to-your-page]

If your page sends a `Cross-Origin-Opener-Policy` header, set it to `same-origin-allow-popups`, or
send none. With `same-origin`, the popup is cut off as soon as it opens checkout: the result
resolves `closed` with reason `unreachable` while the payer may still be paying, and the console
shows a warning naming this fix.

## Check it worked [#check-it-worked]

> Click your button. A popup opens and shows Fianto's checkout page for the session your route
> created. Close the popup after the page has loaded: your code receives `closed` with reason
> `closed_by_payer`.

## Troubleshooting [#troubleshooting]

| You see | Why | Fix |
|---|---|---|
| The popup is blocked every time | Checkout was opened after an `await` or a `setTimeout`, not directly in the click. | Call `openCheckout` synchronously in the click handler. |
| `closed` with reason `unreachable` while the popup still shows checkout | Your page sends `Cross-Origin-Opener-Policy: same-origin`. | Send `same-origin-allow-popups` on the page that opens checkout. |
| No result arrives; the promise resolves `closed` when the popup closes | The page is not on the origin of the session's `success_url`. | Serve the page from the same origin as `success_url`. |
| `CheckoutSessionError` with code `forbidden_origin` (403) | Your checkout route refused a cross-site call. | Call it from your own site, or set `allowedOrigins` on the route. |
| `CheckoutSessionError` with code `internal_error` (500) | Fianto refused for a reason the route does not pass to the browser. | Read the error the route's `onError` logged on your server. |
| `CheckoutSessionError` with code `payment_in_progress` (409) | A payment for this order is already under way. | Wait for it to finish and check the order status before trying again. |
| `fianto:error` with code `no_session_source` | The button has neither `session-endpoint` nor a `session` property. | Set one of them. |

## See also [#see-also]

- [React](/developers/sdks/react): The same flow as a hook and a component.

- [Checkout sessions](/developers/checkout-sessions): What the session carries and how it ends.

- [Quickstart](/get-started/quickstart): Route, button and webhook together.