EPD Elements
Capture cards in the browser with the EPD Elements SDK (epd.js) and attach them to customers with a single-use card_token; your servers never touch raw card data.
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.
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.
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
Your page loads epd.js and initializes it with your publishable key.
The SDK mounts a secure card field into an element you own and style.
On submit, the SDK tokenizes the card and returns a single-use card_token (cct_…).
Your frontend POSTs the card_token to your server.
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.
| Option | Type | Description |
|---|---|---|
apiBaseUrl | string | Override the API host for staging/local development (e.g. http://localhost:3000). Defaults to the production host. |
disableTelemetry | boolean | The secure field’s anonymous telemetry is off by default. Pass false to turn it back on. |
customDomain | string | Serve 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:
type | Field |
|---|---|
'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'):
| Option | Type | Description |
|---|---|---|
style | object | CSS-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'):
| Option | Type | Description |
|---|---|---|
style | object | CSS-in-JS for this one input. See Styling. |
placeholder | string | Placeholder text for the field. |
ariaLabel | string | Accessible label for the input. |
autoComplete | 'on' | 'off' | Toggle browser autofill. Default 'on'. |
disabled | boolean | Render the field read-only. |
iconPosition | 'left' | 'right' | 'none' | 'cardNumber' only: position of the brand icon. |
cardBrand | string | '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.
| Method | Returns | Description |
|---|---|---|
mount(target) | Promise<void> | Mount into a CSS selector ('#card') or a DOM Element. Resolves when the field is ready. |
on(event, listener) | () => void | Subscribe to an event. Returns an unsubscribe function. |
getState() | CardState | Current (PAN-free) state. |
focus() | void | Programmatically focus the input. |
clear() | void | Clear the entered value. |
unmount() | void | Remove 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.
| Event | Fires 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.
| Field | Type | Description |
|---|---|---|
complete | boolean | true once a complete, well-formed card has been entered. |
valid | boolean | true when the current input passes client-side validation. |
empty | boolean | true when every field is empty. |
brand | string | Detected card brand (visa, mastercard, …) or unknown. |
last4 | string (optional) | Last four digits, once enough of the number is entered. |
bin | string (optional) | Detected BIN, once available. |
errors | array (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 });
| Field | Type | Description |
|---|---|---|
token | string | The single-use card_token (cct_…). Send this to your backend. |
expiresIn | number | Seconds until the token expires (900 = 15 minutes). |
brand | string | Card brand, for display only, not authoritative. |
last4 | string | Last 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).
| Variant | Applies when |
|---|---|
base | Always (the default styling). |
complete | The field holds a complete value. |
empty | The field is empty. |
invalid | The 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.
| Property | Type | Description |
|---|---|---|
code | string | Stable error code; branch on this. |
message | string | Human-readable detail. |
type | string | Error category (e.g. invalid_request_error, api_error). |
status | number (optional) | HTTP status, when the error came from the API. |
requestId | string (optional) | Server request id, for support/debugging. |
code | Meaning |
|---|---|
card_invalid | The entered card details are incomplete or invalid. |
invalid_request | The call was malformed (e.g. an incomplete split-field group). |
mount_failed | The element could not be mounted (bad target / DOM not ready). |
tokenization_failed | The card could not be tokenized. |
capture_unavailable | Card capture isn’t configured for this account. |
missing_api_key, invalid_api_key | The publishable key is missing or invalid (HTTP 401, type: authentication_error). |
environment_mismatch | The publishable key is for the wrong mode: a test key on live, or vice versa (HTTP 403, type: authorization_error). |
network_error | The 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_tokenis 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.