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

> ## Agent Instructions
> ## paymove integration rules for AI agents
>
> These rules are verified against the live API. Follow them exactly.
>
> 1. Amounts are INTEGERS in grosze (minor units). `1000` means 10.00 PLN. PLN is the only supported currency and there is no currency field in the API.
> 2. A float `price` (e.g. `12.99`) is SILENTLY TRUNCATED to 12 grosze and still returns HTTP 200. Always send an integer: `Math.round(pln * 100)`.
> 3. Authenticate with the `X-API-KEY` header. Never `Authorization: Bearer`. Sandbox keys start with `sk_test_`, production keys with `sk_live_`.
> 4. Server-side only. A call from a merchant page is rejected. Never put the key in frontend code.
> 5. Unknown request fields are silently ignored and still return HTTP 200 — a wrong body shape looks like success. Match the documented shape exactly.
> 6. A missing `externalId` returns HTTP 500 `Something went wrong`, not 400. A missing `price` or `details.returnUrl` returns HTTP 200 and a usable `redirectUrl` — no error at all. Validate the body yourself before sending it.
> 7. Always verify the `X-Paymove-Signature` header on incoming webhooks before trusting them: https://docs.paymove.io/en/webhook-signature.md
> 8. Errors are `{"status": <int>, "message": "<text>"}`. Branch on the HTTP status only — never on `message`, which is unstable and leaks internal class names.
> 9. There is no rate limiting, no HTTP 429, no HTTP 422, no `Idempotency-Key` header and no API versioning. Do not write code that handles them.
> 10. Re-POSTing an `externalId` that already exists returns HTTP 200 with the ORIGINAL `redirectUrl` and silently discards EVERY field you send — the new price, description and details are all ignored. Use `PATCH /api/pay/product/{productId}/subproduct/{externalId}` to change a price.
> 11. Webhooks fire only on `COMPLETED` by default, and `retries` defaults to `0` (no retries) unless you set it explicitly. Delivery counts as successful when the HTTP status equals `expectedCode` — the response body is never inspected.
> 12. Never fulfil an order on the `returnUrl` redirect. The checkout does not redirect there by itself: the customer has to click "back to shop", and inside the widget modal that redirect never happens. Fulfil only in the webhook handler, after verifying the signature.
> 13. Do not pin a version of `@paymove-io/sdk` — install the latest.
> 14. Full documentation index: https://docs.paymove.io/llms.txt · Copy-paste quickstart: https://docs.paymove.io/en/quickstart.md · Agent skill: https://docs.paymove.io/skill.md

# Browser widget

> sdk.js — the Paymove button and bar on your storefront, plus the checkout opened in a modal without leaving the shop.

The widget is a single `sdk.js` file that adds two things to your shop:

* **the `<paymove-checkout>` element** — a payment button or an information bar with the Paymove mark and payment-method logos, ready to drop into a cart or a list of payment methods,
* **a checkout modal** — the same URL you redirect to today, rendered in an iframe on your own page.

The widget never creates a payment. Payments are created server-side — through the [Node.js SDK](/en/sdk/javascript) or the [REST API](/en/rest-api) — and the widget receives the resulting `redirectUrl`.

<Warning>
  The API key stays on the server. The widget only ever takes a `redirectUrl`, never an `apiKey` — and a browser call to the API would be rejected by CORS anyway.
</Warning>

## 1. Load the script

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<script src="https://checkout.paymove.io/v1/sdk.js" async></script>
```

The script registers the `<paymove-checkout>` element and exposes `window.Paymove`. Everything renders inside a Shadow DOM, so your styles and the widget's styles never leak into each other.

With `async` the script may arrive after your code — wait for `ready()`:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
await window.Paymove?.ready();
```

The `/v1/` path is a versioning alias - the very same file is also served at `/sdk.js`. Prefer `/v1/`, so that a future `/v2/` will not break existing integrations.

<Info>
  The script URL matches the checkout environment. A `sdk.js` build only embeds its own domain, so the production script cannot open a sandbox checkout and vice versa — load it from the same domain your `redirectUrl` points to.
</Info>

## 2. The `<paymove-checkout>` element

The shortest version is the tag alone:

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout label="Pay · 149.00 PLN" methods="BLIK,GPAY,APAY"></paymove-checkout>
```

It renders a button with the Paymove mark and, underneath, a card with the payment-method logos. Attributes configure it:

| Attribute          | Values                     | Default                          | Description                                                        |
| ------------------ | -------------------------- | -------------------------------- | ------------------------------------------------------------------ |
| `mode`             | `payment`, `readonly`      | `payment`                        | `payment` renders a button, `readonly` renders the information bar |
| `label`            | any text                   | `Zapłać` / `Płatności Online`    | Text on the button or bar                                          |
| `methods`          | comma-separated list       | —                                | Methods whose logos are shown                                      |
| `methods-label`    | any text                   | `Dostępne metody płatności`      | Caption in the methods card under the button                       |
| `size`             | `small`, `medium`, `large` | `medium`                         | Button height: 40 / 48 / 56 px                                     |
| `variant`          | `dark`, `light`, `outline` | `dark`                           | Button colour variant                                              |
| `logo`             | `black`, `white`           | matches the variant              | Paymove mark version                                               |
| `arrow`            | `true`, `false`            | `true`                           | Double chevron at the end of the button                            |
| `full-width`       | `true`, `false`            | `false`                          | Stretches the element to the parent's width                        |
| `interactive`      | `true`, `false`            | `false`                          | `readonly` only — makes the bar or tile clickable                  |
| `method`           | method code, e.g. `BLIK`   | —                                | `readonly` only — renders a single-method tile instead of the bar  |
| `description`      | any text                   | description from the SDK catalog | Method tile — custom description under the name                    |
| `hide-label`       | `true`, `false`            | `false`                          | Method tile — hides the name; the logo always stays                |
| `hide-description` | `true`, `false`            | `false`                          | Method tile — hides the description                                |

Text after a `·` renders in bold, so `label="Pay · 149.00 PLN"` gives "Pay · **149.00 PLN**".

The first three logos are shown; the rest collapse into a `+N` badge.

| Method        | Value in `methods` / `method` |
| ------------- | ----------------------------- |
| BLIK          | `BLIK`                        |
| Google Pay    | `GPAY`                        |
| Apple Pay     | `APAY`                        |
| Card payment  | `CARD`                        |
| Bank transfer | `TRANSFER`                    |
| Pay by Link   | `PAY_BY_LINK`                 |
| PayPo         | `PAYPO`                       |

<Info>
  The Paymove mark cannot be hidden or recoloured — two versions are available, black and white. Everything else is themeable — see the **Appearance** section.
</Info>

## 3. Readonly mode

`mode="readonly"` renders the bar alone: the mark, your text and the method logos. It is non-interactive by default — an informational row, the kind you put next to shipping and payment options in a cart.

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout
  mode="readonly"
  label="Online payments"
  methods="BLIK,GPAY,APAY,PAY_BY_LINK,PAYPO"
  full-width
></paymove-checkout>
```

To make the bar itself start a payment, add `interactive` and attach a controller (next section). The element then emits a `paymove:click` event, handles `Enter` and `Space`, and exposes the right roles to screen readers.

### Single-method tiles

The `method` attribute turns the bar into a **tile for one method**: logo, name and description come from the SDK's built-in catalog — the shop maintains no logos or copy of its own. The examples below are live, rendered by the `sdk.js` loaded on this page:

<Frame>
  <div style={{ background: '#ffffff', borderRadius: '12px', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '360px' }}>
    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;BLIK&#x22;" />

    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;GPAY&#x22;" />

    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;APAY&#x22;" />
  </div>
</Frame>

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout mode="readonly" method="BLIK"></paymove-checkout>
<paymove-checkout mode="readonly" method="GPAY"></paymove-checkout>
<paymove-checkout mode="readonly" method="APAY"></paymove-checkout>
```

The logo always stays. The name and description can be hidden (`hide-label`, `hide-description`) or overridden (`label`, `description`):

<Frame>
  <div style={{ background: '#ffffff', borderRadius: '12px', padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '360px' }}>
    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;GPAY&#x22; hide-description" />

    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;BLIK&#x22; hide-label hide-description" />

    <div data-paymove-example="mode=&#x22;readonly&#x22; method=&#x22;PAYPO&#x22; description=&#x22;Buy now, pay later&#x22;" />
  </div>
</Frame>

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout mode="readonly" method="GPAY" hide-description></paymove-checkout>
<paymove-checkout mode="readonly" method="BLIK" hide-label hide-description></paymove-checkout>
<paymove-checkout mode="readonly" method="PAYPO" description="Buy now, pay later"></paymove-checkout>
```

By default the tile has no background, border or padding — it blends into a row of your own methods list, while rows, borders and radio buttons stay on the shop's side. The CSS variables from the **Appearance** section (`--paymove-bg`, `--paymove-border-color`, `--paymove-padding`, `--paymove-radius`) turn it into a standalone card. With `interactive` the tile is clickable and emits `paymove:click` — and with the texts hidden the method name stays in `aria-label`, so screen readers still announce it correctly.

## 4. Opening the checkout in a modal

The modal is driven by a controller from `window.Paymove`:

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout id="pay" label="Pay · 149.00 PLN" methods="BLIK,GPAY,APAY" full-width></paymove-checkout>

<script>
  const checkout = window.Paymove.createCheckout({
    fetchCheckoutUrl: async () => {
      const response = await fetch('/api/payments', { method: 'POST' });
      const { redirectUrl } = await response.json();
      return redirectUrl;
    },
    onSuccess: ({ externalId }) => console.log('paid', externalId),
    onCancel: () => console.log('customer closed the modal'),
  });

  checkout.mount('#pay');
</script>
```

`mount()` takes over clicks on the element and switches the button into a loading state while the URL is fetched. If you already have the `redirectUrl` when the page renders, pass it instead of `fetchCheckoutUrl`:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
const checkout = window.Paymove.createCheckout({ checkoutUrl: redirectUrl });
```

<Info>
  `fetchCheckoutUrl` is the better default: the payment is created at the moment of the click, so customers who only browse the cart never leave abandoned payments behind.
</Info>

### `createCheckout` configuration

| Field              | Description                                                        |
| ------------------ | ------------------------------------------------------------------ |
| `checkoutUrl`      | A ready `redirectUrl` from your backend                            |
| `fetchCheckoutUrl` | Function returning a `redirectUrl`, called on click                |
| `flow`             | `modal` (default) or `redirect`                                    |
| `triggerPayment`   | Method started right after the checkout opens                      |
| `onReady`          | The checkout reported readiness and the modal revealed its content |
| `onSuccess`        | Payment succeeded — `{ externalId }`                               |
| `onComplete`       | Modal closed after a successful payment — `{ externalId }`         |
| `onCancel`         | Modal closed without a payment                                     |
| `onError`          | Checkout error — `{ code, message }`                               |

The configuration also accepts every button appearance field (`label`, `methods`, `variant`…), so you can describe the element entirely in JavaScript and mount it into an empty `<div>`.

<Warning>
  The `externalId` handed to `onSuccess` and `onComplete` is **`details.orderId`** when you passed one while creating the payment, and the 10-character Paymove hash only when you did not. If you match orders on that value, set `details.orderId` consistently so you always receive the same identifier.
</Warning>

### Controller

| Method          | Effect                                                                                   |
| --------------- | ---------------------------------------------------------------------------------------- |
| `mount(target)` | Attaches to a selector or element — inserts `<paymove-checkout>` if there is none inside |
| `unmount()`     | Detaches from the element                                                                |
| `open()`        | Opens the modal (or redirects with `flow: 'redirect'`)                                   |
| `close()`       | Closes the modal                                                                         |
| `update(patch)` | Replaces part of the configuration, e.g. `label` after the cart total changes            |
| `destroy()`     | Cleans everything up: modal, listeners and element                                       |

When you only need to open the modal — with no button of your own — use the shortcut:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
window.Paymove.openCheckout(redirectUrl, {
  onSuccess: ({ externalId }) => console.log('paid', externalId),
});
```

## 5. Redirect instead of a modal

Same configuration, one extra field:

```javascript theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
window.Paymove.createCheckout({
  checkoutUrl: redirectUrl,
  flow: 'redirect',
}).mount('#pay');
```

The customer lands on the Paymove checkout and, after paying, sees a confirmation screen with a "back to shop" button that takes them to the `returnUrl` passed when the payment was created. Modal and redirect differ by this field only, so you can switch between them without touching the rest of the integration.

<Warning>
  In `modal` flow `returnUrl` is not used at all - the back button closes the modal and fires `onComplete`. If your integration relies on a `returnUrl` handler, it will never run inside the modal. The [webhook](/en/webhook-signature) remains the source of truth.
</Warning>

## 6. How the modal behaves

* On desktop it is a **398 px** panel, centred over a dimmed backdrop. Its height follows the checkout content.
* At viewport widths up to **640 px** it slides up from the bottom like a native sheet: at most **90% of the viewport height**, with a drag handle — dragging it down closes the payment.
* While loading, a static Paymove mark is shown. There is no close button at that point — it appears only if the checkout stays silent for 10 seconds, as an escape hatch.
* Once loaded, closing is handled by the checkout's own header. `Esc` and a click on the backdrop work as well.
* The page underneath does not scroll, and returns to its previous position after the modal closes.
* The checkout reports its own height, so the modal does not jump between steps.
* The modal can close itself after a successful payment, but you configure that **when creating the subproduct**, through [`details.autoclose`](/en/rest-api#body-parameters) (in milliseconds), not from the SDK. The checkout then counts down inside the "Back to store" button and closes the modal when it runs out, firing `onComplete`. Any interaction from the customer cancels the countdown.

<Warning>
  Some banks in Pay by Link and PayPo refuse to be embedded in an iframe. The checkout opens those in a new window and returns to the modal afterwards — nothing is required on your side, but do not block pop-ups in your own code.
</Warning>

## 7. Appearance

Colours, radius and typography of the button can be set through the configuration or directly with CSS variables on the element:

| Configuration field | CSS variable             |
| ------------------- | ------------------------ |
| `color`             | `--paymove-bg`           |
| `textColor`         | `--paymove-color`        |
| `borderColor`       | `--paymove-border-color` |
| `radius`            | `--paymove-radius`       |
| `fontSize`          | `--paymove-font-size`    |
| `padding`           | `--paymove-padding`      |
| `fontFamily`        | `--paymove-font-family`  |

```html theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
<paymove-checkout
  label="Pay"
  style="--paymove-bg: #0a0d14; --paymove-radius: 6px"
></paymove-checkout>
```

The widget inherits your page's typeface until you set `--paymove-font-family`.

## 8. React and Next.js

```jsx theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
'use client';

import Script from 'next/script';
import { useEffect, useRef } from 'react';

export function PayButton({ amount }) {
  const host = useRef(null);
  const checkout = useRef(null);

  useEffect(() => {
    let cancelled = false;

    window.Paymove?.ready().then((sdk) => {
      if (cancelled) return;
      checkout.current = sdk.createCheckout({
        label: `Pay · ${amount}`,
        methods: ['BLIK', 'GPAY', 'APAY'],
        fullWidth: true,
        fetchCheckoutUrl: async () => {
          const response = await fetch('/api/payments', { method: 'POST' });
          const { redirectUrl } = await response.json();
          return redirectUrl;
        },
        onSuccess: ({ externalId }) => router.push(`/orders/${externalId}`),
      });
      checkout.current.mount(host.current);
    });

    return () => {
      cancelled = true;
      checkout.current?.destroy();
    };
  }, [amount]);

  return (
    <>
      <Script src="https://checkout.paymove.io/v1/sdk.js" strategy="afterInteractive" />
      <div ref={host} />
    </>
  );
}
```

After the amount changes, `checkout.current.update({ label: 'Pay · 199.00 PLN' })` is enough — the element re-renders without remounting.

<Info>
  In React, mount the widget into an empty `<div>` rather than a JSX `<paymove-checkout>`. The element keeps its content in Shadow DOM, so React has nothing to manage — and you avoid hydration mismatch warnings.
</Info>

## 9. Limits

* `sdk.js` only embeds its own checkout domain. A URL from any other origin fails with `INVALID_CHECKOUT_URL` before anything is shown.
* Payments are always created by your backend — the widget knows no API key and never calls the Paymove API.
* Fulfil orders only after a verified [webhook](/en/webhook-signature). `onSuccess` is a UI signal, not a confirmation of settlement.
* The modal requires your page to be allowed to embed the checkout in an iframe — the checkout sends `Content-Security-Policy` with `frame-ancestors https:`, so your shop must run over HTTPS (except `localhost` in development).
