What this is and who needs it

EPD Elements gives your checkout a secure, ready-made card field: shoppers enter their card, the number goes straight into EPD’s vault, and it never touches your servers, which keeps your PCI compliance at its lightest tier, PCI SAQ A (a short self-check, not a full audit). Under the hood, it’s a tiny JavaScript SDK (epd.js) you drop into your own checkout form: you build and style the form, the SDK captures and tokenizes the card, and hands you a single-use card_token (cct_…) that your backend attaches to a customer with your secret key.

If you collect cards on a website or app and want to save them, charge subscriptions, or run saved-card checkout, this is the integration to use. No browser in the loop, such as phone/MOTO orders (mail-order/telephone-order, where an agent keys the card in by hand), a back-office tool, or a migration? See Inbound Card Capture for the server-to-server path.

EPD Elements is the recommended way to capture cards. See Card Vaulting for the overall concept, or Inbound Card Capture for the server-to-server path.

Two keys, two jobs

EPD Elements uses a second key type alongside your secret key.

Publishable key

epd_live_pk_… / epd_test_pk_…. Lives in the browser. Capture-only, it can tokenize a card and nothing else: it cannot vault, charge, read, or list. Safe to ship in client-side code.

Secret key

epd_live_sk_… / epd_test_sk_…. Server-side only. The full REST API: vault, charge, read. Never expose it in the browser.

Create a publishable key in the dashboard under Settings → Developer & Integrations, on the Publishable Keys tab, using Create Publishable Key (separate from the API Keys tab, where your secret keys live).

The flow

Load the SDK

Your page loads epd.js and initializes it with your publishable key.

Mount a card field

The SDK mounts a secure card field into an element you own and style.

Tokenize on submit

On submit, the SDK tokenizes the card and returns a single-use card_token (cct_…).

Send the token to your backend

Your frontend POSTs the card_token to your server.

Attach it with your secret key

Your backend attaches it to a customer with Add Payment Method.

The card_token is single-use and expires 15 minutes after creation.

1. Add the SDK

The SDK is one hosted file: drop in a single <script> tag. There is no npm package, bundler, or build step; the tag installs a callable global EPD(...).

<script src="https://js.epd.com/element/v1/epd.js"></script>
<!-- installs a global EPD(...) on window -->

Because this script captures card data, always load it over HTTPS directly from js.epd.com, and never self-host or proxy a copy: serving it from the canonical URL is what lets EPD push security fixes to the capture script immediately.

The SDK is currently served from a single https://js.epd.com/element/v1/epd.js channel that tracks the latest 1.x release. This channel is mutable and served without a Subresource Integrity hash, so using it in production means trusting the integrity of EPD’s CDN: treat that as an interim state. Immutable, pinned version URLs with Subresource Integrity hashes, which let the browser reject a tampered capture script, arrive with the first tagged release; each version’s integrity hash will then be published in the dashboard alongside your publishable keys, and you should pin one as soon as it is available.

2. Capture the card

Mount a single combined field (number + expiry + CVC), or three split fields you lay out yourself. Both return the same card_token.

<form id="checkout">
  <div id="card-field"></div>
  <button type="submit">Save card</button>
  <p id="error" role="alert"></p>
</form>

<script src="https://js.epd.com/element/v1/epd.js"></script>
<script type="module">
  // `EPD` is the global installed by the <script> tag above.
  const epd = await EPD('epd_test_pk_…');

  const card = epd.create('card', {
    style: { base: { fontSize: '16px', color: '#1a1a1a' } },
    placeholder: { cardNumber: '1234 1234 1234 1234' },
  });
  await card.mount('#card-field');

  // Drive your own UI from field state.
  const pay = document.querySelector('#checkout button');
  card.on('change', (state) => {
    pay.disabled = !state.complete;
  });

  document.querySelector('#checkout').addEventListener('submit', async (e) => {
    e.preventDefault();
    try {
      const { token } = await epd.createToken(card);
      // Send `token` (a cct_…) to YOUR backend.
      await fetch('/save-card', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ card_token: token }),
      });
    } catch (err) {
      document.querySelector('#error').textContent = err.message;
    }
  });
</script>

Prefer separate inputs for number, expiry, and CVC? Create the three split elements, mount each into its own container, and tokenize them together as a group. Layout and styling are entirely yours.

<form id="checkout">
  <div id="number"></div>
  <span id="brand-icon"></span>
  <div id="expiry"></div>
  <div id="cvc"></div>
  <button type="submit">Save card</button>
  <p id="error" role="alert"></p>
</form>

<script src="https://js.epd.com/element/v1/epd.js"></script>
<script type="module">
  const epd = await EPD('epd_test_pk_…');

  const number = epd.create('cardNumber', { placeholder: '1234 1234 1234 1234' });
  const expiration = epd.create('cardExpiration', { placeholder: 'MM/YY' });
  const cvc = epd.create('cardCvc', { placeholder: 'CVC' });

  await Promise.all([
    number.mount('#number'),
    expiration.mount('#expiry'),
    cvc.mount('#cvc'),
  ]);

  // The number field reports brand/last4 as the holder types.
  const brandIcon = document.querySelector('#brand-icon');
  number.on('change', (state) => {
    brandIcon.dataset.brand = state.brand; // 'visa', 'mastercard', …
  });

  document.querySelector('#checkout').addEventListener('submit', async (e) => {
    e.preventDefault();
    try {
      // Tokenize the trio together → opaque card_token, same as the combined field.
      const { token } = await epd.createToken({ number, expiration, cvc });
      await fetch('/save-card', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ card_token: token }),
      });
    } catch (err) {
      document.querySelector('#error').textContent = err.message;
    }
  });
</script>

3. Attach the card (your backend)

Send the card_token to Add Payment Method with your secret key:

curl -X POST https://api.epd.com/v1/customers/{customer_id}/payment_methods \
  -H "Authorization: Bearer epd_test_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "card_token": "cct_…", "set_as_default": true }'

The response is a standard payment method. From here it behaves exactly like a vaulted card: use it for orders and subscriptions.

Some processors require a billing address to vault a card. If yours does, pass an optional billing_details object (name + address) on the same request; see Add Payment Method.

SDK reference

EPD(publishableKey, options?)

Boots the SDK: loads your account config and prepares the secure field. Returns a Promise that resolves to an instance once the field is ready.

OptionTypeDescription
apiBaseUrlstringOverride the API host for staging/local development (e.g. http://localhost:3000). Defaults to the production host.
disableTelemetrybooleanThe secure field’s anonymous telemetry is off by default. Pass false to turn it back on.
customDomainstringServe the secure field from your own whitelabel host. Requires a provisioned, DNS-validated domain; normally driven from your account config rather than set by hand.

The instance also exposes epd.sandbox (boolean): true when booted with a test (epd_test_pk_…) key.

epd.create(type, options?)

Creates an element. type is one of:

typeField
'card'Combined number + expiry + CVC in one box.
'cardNumber'Split number field (reports brand / last4 / BIN).
'cardExpiration'Split expiry field (MM/YY).
'cardCvc'Split CVC field.

Combined-card options ('card'):

OptionTypeDescription
styleobjectCSS-in-JS by variant. See Styling.
placeholder{ cardNumber?, expirationDate?, cvc? }Per-sub-field placeholder text.
autoComplete'on' | 'off'Toggle browser autofill. Default 'on'.
iconPosition'left' | 'right' | 'none'Position of the card-brand icon.

Split-field options (shared by 'cardNumber', 'cardExpiration', 'cardCvc'):

OptionTypeDescription
styleobjectCSS-in-JS for this one input. See Styling.
placeholderstringPlaceholder text for the field.
ariaLabelstringAccessible label for the input.
autoComplete'on' | 'off'Toggle browser autofill. Default 'on'.
disabledbooleanRender the field read-only.
iconPosition'left' | 'right' | 'none''cardNumber' only: position of the brand icon.
cardBrandstring'cardCvc' only: brand hint so the field sizes itself for 3 vs 4 digits before it’s linked to a number field.

Element methods

Every element, combined or split, shares the same instance API.

MethodReturnsDescription
mount(target)Promise<void>Mount into a CSS selector ('#card') or a DOM Element. Resolves when the field is ready.
on(event, listener)() => voidSubscribe to an event. Returns an unsubscribe function.
getState()CardStateCurrent (PAN-free) state.
focus()voidProgrammatically focus the input.
clear()voidClear the entered value.
unmount()voidRemove the element from the DOM.

Events

Subscribe with element.on(event, listener). The listener receives the current field state plus a type field naming the event.

EventFires when
'ready'The element is mounted and ready for input.
'change'The entered value changes; drive your UI from this.
'focus'The field gains focus.
'blur'The field loses focus.
'keydown'A key is pressed while the field has focus.
card.on('change', (state) => {
  payButton.disabled = !state.complete;
  brandIcon.dataset.brand = state.brand; // 'visa', 'mastercard', …
});

Field state

getState() and the change event both surface a PAN-free snapshot, never the full card number.

FieldTypeDescription
completebooleantrue once a complete, well-formed card has been entered.
validbooleantrue when the current input passes client-side validation.
emptybooleantrue when every field is empty.
brandstringDetected card brand (visa, mastercard, …) or unknown.
last4string (optional)Last four digits, once enough of the number is entered.
binstring (optional)Detected BIN, once available.
errorsarray (optional)Field-level validation errors, if any.

epd.createToken(input)

Tokenizes the entered card and returns a Promise<CardToken>. Pass either a combined card element or the split group { number, expiration, cvc }.

const { token, expiresIn, brand, last4 } = await epd.createToken(card);
// or: await epd.createToken({ number, expiration, cvc });
FieldTypeDescription
tokenstringThe single-use card_token (cct_…). Send this to your backend.
expiresInnumberSeconds until the token expires (900 = 15 minutes).
brandstringCard brand, for display only, not authoritative.
last4stringLast four digits, for display only, not authoritative.

Throws an EpdError if the card is incomplete/invalid, the split group is incomplete, or the request fails.

Styling

The style option is a CSS-in-JS object keyed by variant, with pseudo-selectors and an optional fonts array. Only an allow-listed set of CSS properties is honored (a security boundary: anything else is ignored).

VariantApplies when
baseAlways (the default styling).
completeThe field holds a complete value.
emptyThe field is empty.
invalidThe field fails validation.

Each variant also accepts the pseudo-selectors :hover, :focus, and ::placeholder.

const card = epd.create('card', {
  style: {
    fonts: ['https://fonts.googleapis.com/css2?family=Inter&display=swap'],
    base: {
      fontFamily: "'Inter', sans-serif",
      fontSize: '16px',
      color: '#1a1a1a',
      '::placeholder': { color: '#9ca3af' },
    },
    invalid: { color: '#d92d20' },
    complete: { color: '#027a48' },
  },
});

Errors

createToken and mount reject with an EpdError carrying a stable, machine-readable code. Branch on code; show message to the shopper.

PropertyTypeDescription
codestringStable error code; branch on this.
messagestringHuman-readable detail.
typestringError category (e.g. invalid_request_error, api_error).
statusnumber (optional)HTTP status, when the error came from the API.
requestIdstring (optional)Server request id, for support/debugging.
codeMeaning
card_invalidThe entered card details are incomplete or invalid.
invalid_requestThe call was malformed (e.g. an incomplete split-field group).
mount_failedThe element could not be mounted (bad target / DOM not ready).
tokenization_failedThe card could not be tokenized.
capture_unavailableCard capture isn’t configured for this account.
missing_api_key, invalid_api_keyThe publishable key is missing or invalid (HTTP 401, type: authentication_error).
environment_mismatchThe publishable key is for the wrong mode: a test key on live, or vice versa (HTTP 403, type: authorization_error).
network_errorThe browser could not reach the EPD API.
try {
  const { token } = await epd.createToken(card);
} catch (err) {
  if (err.code === 'card_invalid') {
    showFieldError('Please check the card details.');
  } else {
    showFieldError(err.message);
  }
}

Sandbox

A test publishable key (epd_test_pk_…) tokenizes against your sandbox; the SDK behaves identically. Only the key’s environment differs. Type a test card (e.g. 4111 1111 1111 1111 for a Visa success, or 4000 0000 0000 0002 to force a decline at charge time) into the field. The resulting card_token works against the sandbox Add Payment Method endpoint.

How it protects you

  • The publishable key can only mint capture tokens: a leaked one cannot move money or read data.
  • A card_token is single-use, expires in 15 minutes, and is bound to your merchant: it can’t be replayed or used by another merchant.
  • Capture is rate-limited per merchant and per IP to blunt card-testing.
  • The card field renders in a PCI-DSS Level 1 certified secure iframe; raw PAN/CVV never reach your servers, keeping you in PCI SAQ A scope.

The /elements/* endpoints the SDK calls are documented under Elements for transparency, but you should not call them directly; the SDK manages the publishable-key handshake and token exchange for you.