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

# Payra Elements

> Secure card and bank account fields for your page, in a few lines. The numbers are tokenized before they reach Payra and never pass through your servers.

Payra Elements is the browser library of the Payra API. It mounts the card fields (number, expiration
date, security code) or the US bank account fields (routing and account number, with the ACH
authorization the account holder accepts) inside your page, each number in its own secure frame, and
hands what it collected to a [payment method session](/payment-method-sessions). Your page never sees
card or bank data and never holds a secret key; the only key it carries is the publishable one.

<Steps>
  <Step title="Load the script">
    The library is served by the API itself, so it knows where the API is from where it was loaded.
    Nothing to configure.

    ```html theme={null}
    <script src="https://api-dashboard.payra.com/v1/payra.js"></script>
    ```
  </Step>

  <Step title="Mount the card element">
    Create a session on your server first (it returns the `client_secret`), then:

    ```html theme={null}
    <form id="checkout">
      <div id="card-element"></div>
      <p id="hint"></p>
      <button type="submit" disabled>Pay</button>
    </form>
    <script>
      const submitButton = document.querySelector('#checkout button[type="submit"]');
      const hint = document.querySelector('#hint');
      const payra = Payra('pk_test_...');
      const elements = payra.elements({ clientSecret: 'pms_test_..._secret_...' });
      const card = elements.create('card');
      card.on('change', ({ complete, error }) => {
        submitButton.disabled = !complete;
        hint.textContent = error ? error.message : '';
      });
      card.mount('#card-element');
    </script>
    ```

    `change` fires as the customer types: `complete` says the three fields are valid, `error` names the
    first field with a problem once the customer has left it, `brand` comes from the card number.
  </Step>

  <Step title="Confirm">
    On submit, hand Elements the name on the card (and the billing postal code, if you collect it):

    ```js theme={null}
    document.querySelector('#checkout').addEventListener('submit', async (event) => {
      event.preventDefault();
      const { paymentMethodSession, error } = await payra.confirmPaymentMethodSession(elements, {
        billingDetails: { name: 'Ada Lovelace', postalCode: '94110' },
      });
      if (error) {
        hint.textContent = error.message;   // same shape as the API's errors, plus validation_error
        return;
      }
      // paymentMethodSession.status === 'succeeded'
      // paymentMethodSession.card === { brand: 'visa', last4: '4242', exp_month: 12, exp_year: 2030 }
      // Now ask your server to create the payment method and charge it.
    });
    ```

    Elements sends the card fields through the secure route, which replaces the number and the security
    code with tokens before they reach Payra, then closes the session. Send `paymentMethodSession.id` to your
    server: it [creates the payment method](/payment-methods) from it with the secret key, and charges that.
    `confirmPaymentMethodSession`
    never rejects: a programming mistake (secret key in the page, malformed client secret, missing mount
    target) throws from `Payra()`, `elements()` or `mount()` instead.
  </Step>
</Steps>

## Bank accounts

On a session created with `payment_method_types: ["us_bank_account"]`, mount the bank account element
instead. It draws the routing number and account number fields under their labels, the account type
(checking / savings) and the account holder (individual / company) as radios, preselected on checking and
individual, and the ACH authorization
the customer accepts: the sentence Payra worded for your business (and, on a one-time session, its `amount`) beside an
unticked checkbox, with the full text behind a "View full ACH Authorization" link that opens it in a
dialog. The wording comes from the session; the page cannot change it.

```html theme={null}
<form id="checkout">
  <div id="bank-element"></div>
  <p id="hint"></p>
  <button type="submit" disabled>Pay</button>
</form>
<script>
  const submitButton = document.querySelector('#checkout button[type="submit"]');
  const hint = document.querySelector('#hint');
  const payra = Payra('pk_test_...');
  const elements = payra.elements({ clientSecret: 'pms_test_..._secret_...' });
  const bank = elements.create('us_bank_account');
  bank.on('change', ({ complete, error, authorizationAccepted }) => {
    submitButton.disabled = !complete;   // both numbers valid and the box ticked
    hint.textContent = error ? error.message : '';
  });
  bank.mount('#bank-element');

  document.querySelector('#checkout').addEventListener('submit', async (event) => {
    event.preventDefault();
    // billingDetails.name is the name on the account
    const { paymentMethodSession, error } = await payra.confirmPaymentMethodSession(elements, {
      billingDetails: { name: 'Ada Lovelace', postalCode: '94110' },
    });
    if (error) {
      hint.textContent = error.message;
      return;
    }
    // paymentMethodSession.us_bank_account === { last4: '6789', account_type: 'checking', account_holder_type: 'individual' }
    // paymentMethodSession.ach_authorization.accepted_at is set
  });
</script>
```

The confirm sends the version of the text the customer saw with the tokenized numbers, and Payra stamps the
moment and the address. While the box is unticked, Elements refuses the confirm with `ach_authorization_required`
before anything is sent. `placeholders` (`routing`, `account`) and `labels`
(`routing`, `account`, `accountType`, `accountHolderType`, `viewAuthorization`) reword the parts that are
yours; the choices themselves (Checking, Savings, Individual, Company) and the dialog's title and Close button
are fixed. `style` and `theme` apply as they do to the card, and `focusColor` also colors the radios and the
checkbox. The element lays itself out (the two number fields sit side by side when there is room), so
`layout` does not apply. A card session given `'us_bank_account'`
arrives as `loaderror` with `session_not_bank`.

## Styling

The text inside the fields, the boxes around them and how those boxes sit are all yours:

```js theme={null}
elements.create('card', {
  // Inside the secure fields
  style: { base: { color: '#181d27', fontFamily: 'Inter, sans-serif', fontSize: '16px', '::placeholder': { color: '#717680' } } },
  // The boxes around them, and how much room each one gets
  theme: {
    borderColor: '#d5d7da', focusColor: '#2e90fa', errorColor: '#d92d20',
    borderRadius: '8px', background: '#fff',
    fieldHeight: '44px', fieldGap: '8px', numberMinWidth: '240px',
  },
  // 'auto' (default) keeps one row and wraps when it runs out of room; 'stacked' gives each field a row
  layout: 'auto',
  placeholders: { number: 'Card number', expiry: 'MM / YYYY', cvc: 'CVC' },
});
```

Elements loads no fonts into the secure fields, so a `fontFamily` in `style` only applies when the customer's
device has that font; a web font your page loads does not reach them.

`theme` sets CSS variables on the container you mount into. To set them from your stylesheet instead, target
the container with a selector stronger than one class (for example `#card-element { --payra-focus-color: … }`),
since Elements declares every default on the container itself:

| Option           | Variable                   | Default                                                                                 |
| ---------------- | -------------------------- | --------------------------------------------------------------------------------------- |
| `borderColor`    | `--payra-border-color`     | `#d5d7da`                                                                               |
| `focusColor`     | `--payra-focus-color`      | `#2e90fa`                                                                               |
| `errorColor`     | `--payra-error-color`      | `#d92d20`                                                                               |
| `borderRadius`   | `--payra-border-radius`    | `8px`                                                                                   |
| `background`     | `--payra-background`       | `#fff`                                                                                  |
| `fieldHeight`    | `--payra-field-height`     | `44px`                                                                                  |
| `fieldGap`       | `--payra-field-gap`        | `8px`                                                                                   |
| `numberMinWidth` | `--payra-number-min-width` | `240px`                                                                                 |
| `controlSize`    | `--payra-control-size`     | `16px` (the bank element's radios and checkbox)                                         |
| `controlGap`     | `--payra-control-gap`      | `8px` (between a control and its text; the "View full" link indents by control and gap) |

The container gets the class `PayraElement` (plus `PayraElement--bank` for the bank account element);
each field sits in a `PayraElement-field` that carries `PayraElement--focus`, `PayraElement--invalid`,
`PayraElement--complete` and `PayraElement--empty` as the customer types (the state names Stripe.js uses,
with the `PayraElement` prefix), for anything the variables do not cover. The bank element's labels are
`PayraElement-label`; each radio choice and the checkbox sit in a `<label>` with their text,
`PayraElement-radio` and `PayraElement-consent`; the link is `PayraElement-terms`, and the full text opens in a
`PayraElement-dialog`. The focus glow derives from `focusColor`, and a field with an error gets an `errorColor`
border; the field boxes' transitions turn off under `prefers-reduced-motion`.

### How the fields fit

The card number never gives way to the other two. It keeps `numberMinWidth` — by default enough for a
16-digit number, its brand icon and the padding at the default font — and the row wraps instead of
clipping it: the number takes a row of its own and the expiration date and security code drop below it
together, in a `PayraElement-group`. With the defaults that happens under about `448px` of container
(`450px` if your page keeps the default `box-sizing: content-box`):
the number's `240px`, the gap, and the `112px + 80px + gap` the other two need side by side.

Raise `numberMinWidth` if you set a wider font or take the 19-digit ranges; lower it if your font is
narrower and you would rather keep one row. `layout: 'stacked'` skips the question and gives every field
its own row, whatever the width.

## Events and errors

| Event           | Payload                                                                                                                                                                      |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`         | The fields are on the page.                                                                                                                                                  |
| `change`        | Card: `{ complete, error, brand, empty }`. Bank account: `{ complete, error, empty, authorizationAccepted }`, where `complete` also needs the box ticked                     |
| `focus`, `blur` | `{ field: 'number' \| 'expiry' \| 'cvc' }`, or `'routing' \| 'account'` on the bank account element                                                                          |
| `loaderror`     | `{ error }`: the session could not be opened (wrong key or client secret, or a session that expired, was canceled or already succeeded) or the secure frames could not load. |

Errors from `confirmPaymentMethodSession` use the API's [error shape](/errors): what the API answered, or one of
these that Elements raises itself.

| `type`                  | `code`                                                                                                                                                                                               | When                                                                                                                                                                                                                                                                                    |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`      | `incomplete_number`, `invalid_number`, `incomplete_expiry`, `invalid_expiry`, `incomplete_cvc`, `invalid_cvc`, `incomplete_card`, `cardholder_name_missing`                                          | A field is empty or invalid (the code says which), the card as a whole is not complete, or the name on the card is missing. `param` names the field as Elements knows it (`cardNumber`, `expiry`, `cvv`, `billingDetails.name`).                                                        |
| `validation_error`      | `incomplete_routing_number`, `invalid_routing_number`, `incomplete_account_number`, `invalid_account_number`, `incomplete_bank_account`, `account_holder_name_missing`, `ach_authorization_required` | The bank account element's own: a number is empty or invalid, the account as a whole is not complete, the name on the account is missing, or the authorization box is unticked. `param` names the field (`routingNumber`, `accountNumber`, `billingDetails.name`, `ach_authorization`). |
| `invalid_request_error` | `element_not_mounted`, `element_missing`, `confirm_in_progress`                                                                                                                                      | Confirm called before the element fired `ready`, without any element created, or while a previous confirm is still running.                                                                                                                                                             |
| `api_error`             | `unexpected_response`, `network_error`                                                                                                                                                               | Payra answered something that is not a session; or the confirm never got an answer at all (the secure fields could not reach Payra), which is worth one retry.                                                                                                                          |

A programming mistake throws an error named `PayraSetupError` (with the same `error` shape inside) from
`Payra()`, `elements()`, `create()` or `mount()` instead: `invalid_publishable_key`, `secret_key_in_browser`,
`api_base_url_missing` (the script tag did not tell Elements where the API is; pass the API's origin, without
`/v1`, as `Payra('pk_…', { apiBaseUrl })`), `invalid_client_secret`, `unknown_element`, `mount_target_missing`. A session
the browser cannot open (wrong key or client secret, or a session that expired, was canceled or already
succeeded), a session opened with the wrong element
(`session_not_bank` for a card session on the bank account element, `session_not_card` for the reverse) and
secure frames that fail to load arrive as the `loaderror` event (`collect_missing`, `elements_load_failed`,
`network_error` when Payra could not be reached, or the API's own code).

## Content Security Policy

If your page sets one, allow:

* `script-src`: the API host (`payra.js`) and `https://js.verygoodvault.com`, from which Elements loads the
  secure-field SDK.
* `connect-src`: the API host, which Elements calls to read the session.
* `style-src 'unsafe-inline'`: Elements injects one `<style data-payra-elements>` for the field boxes, without a
  nonce.

The secure-field SDK also reaches these hosts, as seen in a browser during a checkout:

* `frame-src`: `https://js.verygoodvault.com`, for the secure frames.
* `connect-src`: the collection host the session names in `collect.host` (read it from the session your page
  opens; the vault's own hostname when `collect.host` is `null`), through which the confirm is submitted, and
  `https://vgs-collect-keeper.apps.verygood.systems`.

Try one checkout under your policy before going live: a blocked host shows up in the browser console as a
Content Security Policy violation.

## Where it runs

A session is bound to the workspace and environment of the key that created it, so a `pk_test_` page
opens sandbox sessions and a `pk_live_` page opens live ones. A sandbox workspace runs on a test processor, and the
cards it approves, the ways to make it decline and the bank accounts it accepts are that processor's, so ask Payra for
the set that applies to your workspace; there is no Payra-wide test card. A forced decline reads exactly like a real
one (`402 card_declined` with a `decline_code`), so it is the way to try your failure path. A sandbox bank debit
settles on the sandbox processor's own schedule rather than at once: leave the payment `processing` and let the
`payment.succeeded` [webhook](/webhooks) tell you when it settled.
