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

# Elements

> Embed Clink payment inputs in your own checkout page, with your server still creating the Session.

Elements embeds the payment inputs, wallet buttons, 3DS, QR codes, and third-party payment interactions that Clink manages into your own page. The order summary, page structure, pay button, and status messages stay yours.

Use [Hosted Checkout](/build-integration) when you want money moving fastest. Reach for Elements when you need your own order summary, page structure, and interaction design.

<Note>
  `@clink-ai/clink-elements` is currently published at `0.0.1` and the API may still change. Pin the version in `package.json`.
</Note>

## What the full chain looks like

Elements only replaces the frontend segment. **Creating the Session still has to happen on your server**, because that call uses the Secret Key.

```mermaid theme={null}
sequenceDiagram
    participant B as Browser
    participant M as Merchant backend
    participant C as Clink API
    participant E as Clink Elements
    participant W as Merchant webhook

    B->>M: 1. Submit product ID or cart
    M->>M: 2. Validate items and amount, create pending local order
    M->>C: 3. Call POST /checkout/session with the Secret Key
    C-->>M: 4. Return sessionId, url, expireTime and more
    M->>M: Store local order - merchantReferenceId - sessionId mapping
    M-->>B: 5. Return only sessionId
    B->>E: 6. Initialize with sessionId and Publishable Key
    E->>E: 7. Render methods, handle submit, wallets, 3DS, QR codes
    C->>W: 8. Deliver payment events
    W->>M: Verify, deduplicate, update the local order
    B->>M: 9. Query merchant order status
    M-->>B: Return the final result
```

Step 1 submits a **product identifier**, not a final amount computed in the browser. The amount is recalculated server-side in step 2.

### Who owns what

| Party                | Owns                                                                                                                                                | Must not                                                                                                 |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Merchant frontend    | Calling your own wrapper endpoint, initializing and mounting Elements, updating UI from SDK events, querying merchant order status                  | Hold the Secret Key, call Clink's Create Session directly, or decide on its own that a payment succeeded |
| Merchant backend     | Validating items and amounts, creating the local order, calling Clink, storing the ID mapping, returning a frontend-safe payload, handling webhooks | Pass the Secret Key or Clink's full raw response to the browser                                          |
| Clink API / Elements | Creating the Session, rendering secure payment UI, handling submission, third-party buttons, 3DS, and QR codes                                      | Own your local order or your fulfillment                                                                 |
| Clink webhook        | Delivering server-side payment state to your backend                                                                                                | Be replaced by frontend events                                                                           |

## 1. Server: write your own Session endpoint

This layer is not an optional optimization. It is three boundaries at once: **the Secret Key security boundary, the amount validation boundary, and the local order association boundary.**

The `clinkRequest` helper below is the authenticated helper from [Hosted Checkout](/build-integration); it returns the `data` field of the response.

```javascript theme={null}
// This is your own backend endpoint, not a Clink browser API
app.post('/api/payments/elements-session', requireUser, async (req, res) => {
  const { productId, quantity } = req.body;

  // Recalculate server-side; never trust a price from the browser
  const quote = await catalog.quote(productId, quantity);

  const order = await orders.createPending({
    customerId: req.user.id,
    amount: quote.amount,
    currency: quote.currency,
  });

  const session = await clinkRequest('/checkout/session', {
    customerEmail: req.user.email,
    merchantReferenceId: order.id,
    originalAmount: quote.amount,
    originalCurrency: quote.currency,
    priceDataList: quote.items,
    uiMode: 'elements',
    returnUrl: `${APP_ORIGIN}/payment/return?session_id={ELEMENTS_SESSION_ID}`,
  });

  await orders.bindSession(order.id, session.sessionId);

  // This is your own frontend response — keep it minimal
  res.json({ sessionId: session.sessionId });
});
```

Three things to get right:

* `uiMode` is `elements` and `returnUrl` is required. Put `{ELEMENTS_SESSION_ID}` in the URL and Clink substitutes the real Session ID
* The field is `returnUrl`. Older material calls it `redirectUrl`, which is wrong — `redirectUrl` is a response field used in `requires_action` flows
* `merchantReferenceId` is for reconciliation only. It is **not an idempotency key**; the same value twice gives you two Sessions

### What Clink returns

The `data` that `clinkRequest` hands back looks like this:

```json theme={null}
{
  "code": 200,
  "msg": "success",
  "data": {
    "sessionId": "sess_xxxxxxxx",
    "uiMode": "elements",
    "returnUrl": "https://merchant.example.com/payment/return?session_id=sess_xxxxxxxx",
    "url": "https://uat-checkout.clinkbill.com/...",
    "merchantReferenceId": "merchant_order_xxx",
    "expireTime": "2026-07-30T12:00:00Z"
  }
}
```

That is an excerpt; the full field list is in [Create checkout session](/api-reference/endpoint/create-checkout-session). Two things to note:

* **The response contains no Publishable Key and no `environment`.** Both come from your own application config — see the next section
* The response **does** contain `url`, the hosted checkout address. An Elements integration does not use it

<Warning>
  Do not forward Clink's raw response to the browser. Your endpoint should return only what the frontend actually needs — ideally just `sessionId`.
</Warning>

## 2. Frontend: call your own endpoint, then initialize

The browser does two things: ask your backend for a `sessionId`, then initialize the SDK with it.

```javascript theme={null}
import { loadClinkElements } from '@clink-ai/clink-elements';

// This calls your own backend, not Clink
const response = await fetch('/api/payments/elements-session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ productId, quantity }),
});

if (!response.ok) throw new Error('Unable to create checkout session');
const { sessionId } = await response.json();

const clink = await loadClinkElements({
  sessionId,
  publishKey: PUBLIC_CLINK_PUBLISHABLE_KEY,
  environment: 'sandbox',
  presetOptions: {
    locale: 'en-US',
    theme: 'light',
    primaryColor: '#1677FF',
  },
});
```

`PUBLIC_CLINK_PUBLISHABLE_KEY` and `environment` are **your application's deployment config**, taken from the Publishable Key under **Developers > API Keys** (it starts with `pk_uat_`). They are safe to expose in the browser, but they do not come from the Create Session response.

<Tabs>
  <Tab title="Vite">
    ```bash theme={null}
    # .env
    VITE_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
    VITE_CLINK_ENVIRONMENT=sandbox
    ```
  </Tab>

  <Tab title="Next.js">
    ```bash theme={null}
    # .env.local
    NEXT_PUBLIC_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
    NEXT_PUBLIC_CLINK_ENVIRONMENT=sandbox
    ```
  </Tab>
</Tabs>

<Note>
  The SDK parameter is named `publishKey`. When this page says Publishable Key, that is the field it means — write `publishKey` in code.
</Note>

## 3. Mounting, submitting, and third-party buttons

Two kinds of button can appear on a checkout page, and they behave differently:

**Your own pay button** — for cards and anything else where the host triggers submission. Call `clink.submit()` on click.

**SDK built-in third-party buttons** — Apple Pay, Google Pay, PayPal, and similar. The `paymentMethod` element **renders these internally** and handles the click itself. When one takes over, the SDK emits `submit-visible: false` and you hide your own button.

```javascript theme={null}
const paymentMethod = clink.createElement('paymentMethod');
paymentMethod.mount('#payment-method');

// Only if customers pick a currency, and always after paymentMethod
const currencySelect = clink.createElement('currencySelect');
currencySelect.mount('#currency-select');

let canSubmit = false;
let submitting = false;

clink.on('submit-enabled', (enabled) => {
  canSubmit = enabled;
  payButton.disabled = !enabled || submitting;
});

// Hide your own button when a third-party button takes over
clink.on('submit-visible', (visible) => {
  payButton.hidden = !visible;
});

payButton.addEventListener('click', () => {
  if (!canSubmit || submitting) return;
  submitting = true;
  payButton.disabled = true;
  clink.submit();
});
```

<Warning>
  Three things not to do:

  * **Do not hardcode button visibility by payment method name.** Follow `submit-visible`
  * **Do not draw your own Apple Pay, Google Pay, or PayPal buttons.** The SDK renders those; yours would do nothing when clicked
  * **Do not call `clink.submit()` for third-party buttons.** The SDK handles their clicks

  Which third-party methods appear depends on merchant configuration, the Session, currency, browser, and device. Do not promise a fixed set on your page.
</Warning>

`submit-enabled` reports whether submission is allowed. Write `disabled = !enabled` rather than passing the event value straight into `disabled`, which inverts the logic.

## 4. Events

Two groups, by what you do with them.

**Update host UI**

| Event                  | What to do                                                    |
| ---------------------- | ------------------------------------------------------------- |
| `session-init-success` | Clear your own skeleton state                                 |
| `submit-enabled`       | Enable or disable the pay button and promo code controls      |
| `submit-visible`       | Hide your own pay button when a third-party button takes over |
| `amount-change`        | Update total, currency, discount, tax, and button label       |
| `promo-code-error`     | End the promo code loading state and show a field-level error |
| `error`                | Branch by error type — see the table below                    |

**Flow signals**

| Event             | What to do                        |
| ----------------- | --------------------------------- |
| `session-success` | Navigate to your own result page  |
| `session-pending` | Show a "confirming payment" state |

<Warning>
  `session-success` is **not proof of payment**, and neither is `returnUrl`. Browser events can be forged.

  Do this instead: on `session-success`, navigate to your own result page, and have that page query **your own backend** for the order status. Shipping, top-ups, and entitlements are decided by your backend from signature-verified webhooks and server-side queries.

  Verification, deduplication, and order matching are covered in [Hosted Checkout](/build-integration).
</Warning>

## 5. Promo codes

Optional. Creating Coupons and Promotion Codes server-side is covered in [Discounts and promotion codes](/promotions). This section is frontend only.

```javascript theme={null}
clink.on('amount-change', ({ amount }) => {
  promoSection.hidden = !amount.enablePromotionCode;
  renderApplied(amount.promotionCodeInfo);
  updateTotal(amount.dueTodayAmount, amount.currency);
});

applyButton.addEventListener('click', () => {
  setPromoLoading(true);
  clink.promoCodeChange({ type: 'apply', code: promoInput.value });
});

removeButton.addEventListener('click', () => {
  setPromoLoading(true);
  clink.promoCodeChange({ type: 'clear' });
});

clink.on('promo-code-error', ({ message }) => {
  setPromoLoading(false);
  showPromoError(message);
});
```

The payload is `{ amount }`, with the fields nested under `amount`. Writing `(info) => info.enablePromotionCode` yields `undefined`, and the promo code entry never appears.

Discounts and the final amount due come from `amount`. Do not compute them yourself.

## 6. Error handling

| Error                      | When                                  | What to do                                                              |
| -------------------------- | ------------------------------------- | ----------------------------------------------------------------------- |
| `SessionExpiredError`      | The Session expired                   | Have your backend create a new Session and reinitialize                 |
| `SessionCompleteError`     | This Session was already paid         | Show a completed state and refresh your own order                       |
| `SessionLoadError`         | Loading failed                        | Allow a retry, or recreate the Session                                  |
| `SessionNotSupportedError` | This Session is not for Elements      | The backend did not send `uiMode: elements`; fix it and recreate        |
| `ClinkApiError`            | Merchant or Session validation failed | Check that `publishKey`, `environment`, and `sessionId` belong together |
| `PromoCodeError`           | The promo code is invalid             | Keep the payment form usable and show the error in the promo area       |

`SessionNotSupportedError` is the most common one early on, and almost always means the backend is still sending `uiMode: "hostedPage"`.

## 7. Implementation constraints

Get these wrong and the integration breaks. Everything else about layout is your own design decision.

| Constraint                            | What goes wrong                                           | What to do                                                                                                                        |
| ------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| One instance per Session              | Reusing an old Session loads a completed or expired state | Destroy the old instance and reinitialize when `sessionId` changes                                                                |
| Fixed creation order                  | Creating `currencySelect` first throws                    | `paymentMethod` first, then the optional `currencySelect`; each element type once per instance                                    |
| Teardown must clean up                | Orphaned iframes and window message listeners             | Call `destroy()` on route change or component unmount. Repeat calls are safe to ignore, but a destroyed instance cannot be reused |
| Stable container with flexible height | Wallet, QR code, or 3DS interactions get clipped          | Do not give the payment iframe's parent a fixed height, and do not clip dynamic content with `overflow: hidden`                   |
| Browser-only execution                | SSR fails when it touches the DOM                         | In Next.js and similar, put it in a client component or a client-side init step                                                   |

## Before you ship

* [ ] No Secret Key and no webhook signing key in browser Network traffic or the build output
* [ ] The browser only calls your own Session wrapper endpoint, never Clink's Create Session directly
* [ ] The backend does not trust a final amount from the browser; it recalculates from the product or cart
* [ ] The local order, `merchantReferenceId`, and `sessionId` mapping is stored
* [ ] The Publishable Key and `environment` come from application config, and are not described as Create Session response fields
* [ ] `submit-visible` hides your own button when Apple Pay, Google Pay, PayPal, or similar appear
* [ ] `session-success` does not trigger fulfillment; the final state comes from verified webhooks and backend queries
* [ ] Old instances are destroyed when the Session changes or the page unmounts

## Next

<CardGroup cols={2}>
  <Card title="Hosted Checkout" icon="wrench" href="/build-integration">
    The backend and webhook halves are identical to Hosted Checkout.
  </Card>

  <Card title="Go live" icon="rocket" href="/go-live">
    The checklist before switching to production.
  </Card>
</CardGroup>
