# JavaScript Low-Code SDK

> Add a configurable Pay button to any web page, choose the amount source and theme, and open Paymos checkout without building a backend form.

The Low-Code SDK lets you embed a payment button directly on your website. When a customer clicks it, the SDK resolves the payment amount, creates an invoice via your public SDK key (`pk_...`), and opens the checkout form — either as an **iframe modal** or a **full-page redirect**.

The Low-Code SDK is a convenience wrapper around the same hosted checkout page that `POST /v1/invoices` returns as `payment_url`; in iframe mode it opens that checkout with `?embed=true`. See [Hosted Checkout](/docs/hosted-checkout.md) for the raw redirect and manual iframe contract.

Under the hood the SDK posts to `https://paymos.io/public/v1/sdk` — the same origin that serves `paymos-widget.js` — with your key in the `X-Sdk-Key` header.

If no amount is available and you passed a `pos_url`, the button opens that **Terminal** page instead — a numpad where the amount is entered manually. Without `pos_url`, a click with no resolvable amount throws and emits `paymos:error`.

## How it works

:::dash-numbered
- Add the SDK script and a mount point to your page
- Call `PaymosWidget.mount()` with your `api_key`, `project_id`, and options
- Customer clicks the button
- SDK resolves the amount (static value, callback, or input selector)
- If amount is available — SDK creates an invoice and opens checkout
- If no amount and `pos_url` is set — SDK opens the Terminal numpad at that URL
- If no amount and no `pos_url` — SDK throws and emits `paymos:error`
- Payment is processed — webhook sent to your registered webhook URL
:::

## Installation

```html
<script src="https://paymos.io/v1/paymos-widget.js"></script>
<div id="paymos-widget"></div>
```

## Resolving the amount

The SDK resolves the payment amount at the moment the button is clicked, using the first available source:

:::dash-numbered
- `amount` — a fixed decimal string passed in the config. Best for single-product pages or fixed-price items.
- `get_amount()` — a callback function that returns the amount as a decimal string. Use this when the total is computed dynamically (e.g. from a shopping cart).
- `amount_selector` — a CSS selector pointing to an `<input>` element. The SDK reads its `.value` at click time. Useful when the customer types the amount themselves.
:::

If none of the above returns a valid positive decimal string, the SDK opens the **Terminal** at your `pos_url` — a numpad where the amount is entered manually. If you didn't set `pos_url`, the click throws `PaymosWidget requires an amount, amountSelector, or price_id.` and no checkout opens.

```javascript
// Fixed price (price_id) — RECOMMENDED for fixed products.
// Create the price once in the dashboard; the server resolves the amount and
// currency from it, so a buyer cannot change them in DevTools. No amount in the embed.
PaymosWidget.mount('#paymos-widget', {
  api_key: 'YOUR_PUBLIC_KEY',
  price_id: 'price_YOUR_PRICE_ID'
});

// Fixed amount — client-supplied (editable in the browser; use for donations / free-amount)
PaymosWidget.mount('#paymos-widget', {
  api_key: 'YOUR_PUBLIC_KEY',
  project_id: 'prj_YOUR_PROJECT_ID',
  amount: '25.00',
  currency: 'USD'
});

// Dynamic amount — computed at click time
PaymosWidget.mount('#paymos-widget', {
  api_key: 'YOUR_PUBLIC_KEY',
  project_id: 'prj_YOUR_PROJECT_ID',
  get_amount: () => calculateCartTotal().toFixed(2),
  currency: 'USD'
});

// Input selector — reads value from an <input> element
PaymosWidget.mount('#paymos-widget', {
  api_key: 'YOUR_PUBLIC_KEY',
  project_id: 'prj_YOUR_PROJECT_ID',
  amount_selector: '#donation-amount',
  currency: 'USD'
});
```

## Two integration modes

The SDK exposes two entry points. Pick whichever fits your page:

### `mount(selector, opts)` — SDK renders the button

The SDK injects a fully-styled, ready-to-use Pay button into the target element (its text comes from the `label` option, default `"Support with crypto"`). Best for single-product pages, simple landings, donation widgets — anywhere you want zero CSS work.

```html
<script src="https://paymos.io/v1/paymos-widget.js"></script>
<div id="paymos-widget"></div>
<script>
  PaymosWidget.mount('#paymos-widget', {
    api_key: 'pk_live_…',
    project_id: 'prj_…',
    amount: '25.00',
    currency: 'USD',
    label: 'Support with crypto'
  });
</script>
```

### `open(opts)` — you render the button, SDK opens the overlay

You keep full control of the button DOM (your design system, your CSS, your icons) and call `PaymosWidget.open(opts)` from a click handler. Best for pricing pages with multiple plans, shopping carts, custom UI frameworks (React, Vue, Svelte components).

```html
<button id="buy-growth">Subscribe to Growth — $79/mo</button>
<script>
  document.getElementById('buy-growth').addEventListener('click', function () {
    PaymosWidget.open({
      api_key: 'pk_live_…',
      project_id: 'prj_…',
      amount: 79,
      currency: 'USD',
      client_id: 'plan:growth'
    });
  });
</script>
```

Both modes hit the same backend, fire the same webhooks, and emit the same window events. The choice is purely about button styling responsibility — `open()` is what the dashboard's **Open Demo** link launches (a pricing page with three independently-styled buttons, all routed through one shared overlay).

## When to use the SDK vs the REST API

The Low-Code SDK is a **browser-side** tool — it puts a Pay button on a static HTML page without a backend. That makes it perfect for:

- Donations / tip jars (the customer picks the amount)
- POS / cashier-driven flows (staff types the amount)
- Free-amount widgets ("name your price")
- Internal sandbox / staging demos

:::warning
For anything where the price is fixed on your side (subscriptions, digital goods, physical orders, courses, licences), remember the amount lives in the page and is editable from DevTools — a determined customer can submit `0.01` for your `$99` plan. Use the [REST API](/docs/invoices/create.md) from your server with the secret key instead. The SDK isn't broken; this is the standard split shared by every client-side checkout (Stripe, LemonSqueezy, PayPal): publishable key on the page, secret key on the server.
:::

## Configuration

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `api_key` | `string` | Yes | Public SDK key from the dashboard (`pk_...`) |
| `project_id` | `string` | Yes, unless `price_id` is provided | Project identifier (`prj_...`) |
| `price_id` | `string` | No | Fixed price created in the dashboard (`price_...`). The server resolves amount and currency from it, so the buyer can't edit them — recommended for fixed products. Takes precedence over every amount source below |
| `amount` | `string \| number` | No | Fixed payment amount as a decimal string or number |
| `get_amount` | `function` | No | Callback returning the amount as a decimal string at click time |
| `amount_selector` | `string` | No | CSS selector of an input with the amount |
| `currency` | `string` | No | Fiat currency code (`USD`, `EUR`, etc.). Default: `USD` |
| `client_id` | `string` | No | Your internal customer ID (attached to the invoice) |
| `mode` | `string` | No | `"iframe"` (default) — modal overlay. `"redirect"` — full-page navigation |
| `label` | `string` | No | Button text. Default: `"Support with crypto"` |
| `width` | `string` | No | `"auto"` (default) or `"full"` for 100% width |
| `accent_color` | `string` | No | Button accent color (hex). Default: `#ff6b35` |
| `text_color` | `string` | No | Button text color (hex). Default: `#ffffff`. The contrast check runs only when you also pass a custom `accent_color`: if your text color clears 4.5:1 against it the SDK keeps it, otherwise it picks black or white by measured contrast. On the shipped default accent the check is skipped and your value stands as given |
| `radius` | `number` | No | Button border-radius, 0-32 px. Default: `18` |
| `frame_title` | `string` | No | Accessible title for the iframe element. Default: `"Paymos checkout"` |
| `aria_label` | `string` | No | Accessible name for the modal dialog. Default: `"Paymos checkout"` |
| `pos_url` | `string` | No | URL of your POS/Terminal page. When set, the button opens the Terminal if no amount resolves |
| `target` | `string` | No | `"_self"` (default) or `"_blank"` (only for redirect mode) |
| `auto_close_delay` | `number` | No | Milliseconds to keep the success screen visible before the modal self-dismisses. Default: `0` (disabled). Cancelled by any user interaction inside the modal. |
| `haptics` | `boolean` | No | Enable short haptic feedback on supporting platforms (Android, some PWAs). iOS Safari ignores the Vibration API — no-op there. Honors `prefers-reduced-motion`. Default: `true` |
| `timeout` | `number` | No | Milliseconds before the invoice-creation request is aborted. Values must be positive; anything else falls back to the default. Default: `30000` |
| `debug` | `boolean` | No | Log full `Error` objects to the console instead of the message alone. Default: `false` |

## Full example

```html
<script src="https://paymos.io/v1/paymos-widget.js"></script>

<div id="paymos-widget"></div>

<script>
  PaymosWidget.mount('#paymos-widget', {
    api_key: 'YOUR_PUBLIC_KEY',
    project_id: 'prj_YOUR_PROJECT_ID',
    amount: '25.00',
    currency: 'USD',
    label: 'Support with crypto',
    mode: 'iframe',
    accent_color: '#FF6B35',
    text_color: '#FFFFFF',
    radius: 20
  });
</script>
```

## Programmatic API

You can also open checkout without mounting a button:

```javascript
// Open checkout programmatically (no button)
await PaymosWidget.open({
  api_key: 'YOUR_PUBLIC_KEY',
  project_id: 'prj_YOUR_PROJECT_ID',
  amount: '50.00',
  currency: 'USD',
  mode: 'iframe'
});

// Close the modal
PaymosWidget.close();

// Remove the button and clean up
PaymosWidget.destroy('#paymos-widget');
```

## Events

The SDK dispatches `CustomEvent`s on `window` so your page can react to the checkout lifecycle. All payloads live on `event.detail`.

| Event | When it fires | `event.detail` |
|---|---|---|
| `paymos:opened` | The modal is mounted and the iframe URL has been set. | `{ url }` |
| `paymos:closed` | The modal is removed (any reason, including the optional `auto_close_delay` timer firing). | `{ reason: 'escape' \| 'iframe' \| 'replaced' \| 'api' \| 'success' }` |
| `paymos:succeeded` | The customer's payment reached `paid` or `paid_over`. Emitted from inside the iframe via `postMessage`. | `{ invoiceId }` |
| `paymos:failed` | Terminal failure state (`underpaid`, `expired`, `cancelled`). | `{ invoiceId, reason }` |
| `paymos:error` | The SDK couldn't even create the invoice (network/auth/CORS/timeout). | `{ message }` |

The `succeeded` / `failed` events fire only when the checkout is opened in iframe mode — they're delivered by the payment page calling `window.parent.postMessage`. The SDK accepts a message only from the origin of the checkout URL it embedded, and only from that iframe's own `contentWindow`, so no third party on the merchant page can spoof them.

For redirect-mode checkouts the page navigates away before the result is known, so use a webhook on your server to confirm settlement.

```javascript
// 1. The customer paid — auto-close the modal and redirect to a thank-you page.
window.addEventListener('paymos:succeeded', (e) => {
  console.log('Paid:', e.detail.invoiceId);
  PaymosWidget.close();
  // Absolute and on your own domain: a root-relative path in a published
  // code sample gets crawled as one of OUR URLs — Googlebot did exactly that
  // and filed /thank-you?order= as a 404 against paymos.io.
  window.location.href = 'https://your-shop.example/thank-you?order=' + e.detail.invoiceId;
});

// 2. Payment failed (underpaid / expired / cancelled).
window.addEventListener('paymos:failed', (e) => {
  console.warn('Payment failed:', e.detail.invoiceId, e.detail.reason);
  // e.detail.reason: 'underpaid' | 'expired' | 'cancelled'
});

// 3. Modal lifecycle.
window.addEventListener('paymos:opened',  (e) => console.log('Opened',  e.detail.url));
window.addEventListener('paymos:closed',  (e) => console.log('Closed',  e.detail.reason));
//   e.detail.reason: 'escape' | 'iframe' | 'replaced' | 'api' | 'success'

// 4. Invoice creation failed (network down, bad config, server error).
window.addEventListener('paymos:error', (e) => {
  console.error('SDK error:', e.detail.message);
});
```

## Allowed origin

The SDK only works on domains that match **Allowed origin**, set in the **Integration** card on the [**Low-Code**](/low-code) page. Requests from other origins are rejected.

## Terminal fallback

The fallback is opt-in: point `pos_url` at your Terminal page. When no valid amount is resolved (no `amount`, `get_amount()` returns null, or `amount_selector` input is empty), the SDK opens that URL — a numpad where the amount is entered manually. This is useful for point-of-sale scenarios where the total varies per transaction.

Leave `pos_url` unset and the same case throws `PaymosWidget requires an amount, amountSelector, or price_id.` instead, which reaches your page as a `paymos:error` event.
