Embedded Card UI

Learn how to embed card PANs and CVV codes.

Handling full card PANs and CVV codes requires that you comply with the Payment Card Industry Data Security Standards (PCI DSS). Some clients choose to reduce their compliance obligations by using our embedded card UI solution documented below.

In this setup, PANs and CVV codes are presented to the end-user via a card UI that we provide, optionally styled in the customer's branding using a specified CSS stylesheet.

A user's browser makes the request directly to api.lithic.com, so card PANs and CVVs never touch the API customer's servers while full card data is displayed to their end-users.

The response contains an HTML document. This means that the URL for the request can be inserted straight into the src attribute of an iframe.

You should compute the request payload on the server-side. You can render it (or the whole iframe) on the server or make an ajax call from your front-end code, but do not embed your API key into front-end code, as doing so introduces a serious security vulnerability.

API Reference: Embedded card UI

GET https://api.lithic.com/v1/embed/card

Requests

curl https://api.lithic.com/v1/embed/card?embed_request=eyJjc3Mi..&hmac=u...
embed_requestA base64 encoded JSON string of an Embed Request to specify which card to load
hmacSHA2 HMAC of the embed_request JSON string with base64 digest

Embed Request Schema

{
  "token": String,
  "css": String,
  "account_token": String,
  "expiration": String
}
account_tokenOnly needs to be included if one or more end-users have been enrolled
cssA publicly available URI, so the white-labeled card element can be styled with the client's branding
expiration (optional)An ISO 8601 timestamp for when the request should expire.
tokenGlobally unique identifier for the card to be displayed

For the expiration parameter, if no timezone is specified, UTC will be used. If payload does not contain an expiration, the request will never expire.

🚧

Using an expiration reduces the risk of a replay attack. Without supplying the expiration, in the event that a malicious user gets a copy of your request in transit, they will be able to obtain the response data indefinitely.

Response

The endpoint returns an HTML document similar to the one below. It is up to the API client to provide CSS styles for these elements in the Embed Request. You can always rely on the card, pan, expiry, cvv, and alert ids, as well as the pan-separator class. You shouldn't make any other assumptions about the structure of the document as it could change at any time.

Note that using the default style sheet there is no visual indication that copying is happening on-click, and you may need to add on-click styling yourself.

<html>
<head>
<link rel="stylesheet" type="text/css" href="{{ css_uri }}">
<style>
    #alert { display: none; }
</style>
<script type="text/javascript">
    var timeout;

    function clearAlertDelay() {
        clearTimeout(timeout);
        var messageDiv = document.getElementById('alert');
        timeout = window.setTimeout(
            function() {
                messageDiv.className = "empty";
                messageDiv.innerText = "";
            },
            1200
        );
    }

    function copySuccess(divId) {
        var messageDiv = document.getElementById('alert');
        messageDiv.innerText = divId + " copied to clipboard";
        messageDiv.className = "success";
        console.log('Copying to clipboard was successful!');
        clearAlertDelay();
      
        // Only included if target_origin is in the embed request
        window.parent.postMessage({copyElt: divId, isCopied: true}, '{{ target_origin }}');
    }

    function copyFailed(divId) {
        var messageDiv = document.getElementById('alert');
        messageDiv.innerText = "error copying " + divId;
        messageDiv.className = "error";
        console.error('Copying to clipboard failed');
        clearAlertDelay();
        
        // Only included if target_origin is in the embed request
        window.parent.postMessage({copyElt: divId, isCopied: false}, '{{ target_origin }}');
    }

    function copyToClip(divId) {
        var messageDiv = document.getElementById('alert');
        var copyEl = document.getElementById(divId);
        var copyText = copyEl.textContent;
        navigator.clipboard.writeText(copyText)
            .then(function () {
                copySuccess(divId);
                clearAlertDelay();
            })
            .catch(function(err) {
                try {
                    var copied = false;
                    if (document.createRange) {
                        range = document.createRange();
                        range.selectNode(copyEl)
                        select = window.getSelection();
                        select.removeAllRanges();
                        select.addRange(range);
                        copied = document.execCommand('copy');
                        select.removeAllRanges();
                    }
                    else {
                        range = document.body.createTextRange();
                        range.moveToElementText(copyEl);
                        range.select();
                        copied = document.execCommand('copy');
                    }

                    if (copied) {
                        copySuccess(divId);
                    }
                    else {
                        copyFailed(divId);
                    }
                }
                catch (err) {
                    copyFailed(divId);
                }
                clearAlertDelay();
            })
    }
</script>
</head>
<body>
    <div id="card">
        <div id="pan" onclick="copyToClip('pan')">9999<span class='pan-separator'></span>9999<span class='pan-separator'></span>9999<span class='pan-separator'></span>9999</div>
        <div id="expiry">
            <span id="month" onclick="copyToClip('month')">08</span>
            <span id="separator">/</span>
            <span id="year" onclick="copyToClip('year')">27</span>
        </div>
        <div id="cvv" onclick="copyToClip('cvv')">574</div>
        <div id="alert" class="empty"></div>
    </div>
</body>
</html>

Creating a Request

See below for example implementations for creating an embed request and HMAC.

import base64
import hashlib
import hmac
import json

import requests  # pip install requests


def to_json_str(json_object):
    return json.dumps(json_object, sort_keys=True, separators=(',', ':'))

  
def hmac_signature(key, msg):
    hmac_buffer = hmac.new(
        key=bytes(key, 'utf-8'),
        msg=bytes(msg, 'utf-8'),
        digestmod=hashlib.sha256
    )
    return base64.b64encode(hmac_buffer.digest()).decode('utf-8')

  
def embed_request_query_params(api_key, card_uuid, css_url, account_token, target_origin):
    embed_request_dict = {
        # Globally unique identifier for the card to display
        "token" : card_uuid,
        # Stylesheet URL to style the card element
        "css": css_url,
        # Only required if one or more end-users have been enrolled
        "account_token": account_token,
    }
    
    if target_origin:
        # Only required if you want to post the element clicked to the parent iframe
        embed_request_dict["target_origin"] = target_origin
        
    embed_request_json = to_json_str(embed_request_dict)

    embed_request = base64.b64encode(bytes(embed_request_json, 'utf-8')).decode('utf-8')
    embed_request_hmac = hmac_signature(api_key, embed_request_json)

    return {
        "embed_request": embed_request,
        "hmac": embed_request_hmac,
    }


def get_embed_html(api_key, card_uuid, css_url, account_token=None, target_origin=None):
    url = "https://api.lithic.com/v1/embed/card"

    headers = {
        "Accept": "text/html",
        "Authorization": f"{api_key}",
    }

    params = embed_request_query_params(api_key, card_uuid, css_url, account_token, target_origin)

    response = requests.request("GET", url, params=params, headers=headers)
    response.raise_for_status()
    
    return response.text
const crypto = require("crypto");
const https = require("https");


function hmacSignature(key, msg) {
    return crypto.createHmac("sha256", key)
                 .update(msg)
                 .digest("base64");
}

function embedRequestQuery(apiKey, cardUuid, cssUrl, accountToken, targetOrigin) {
  const queryParams = {
    "token" : cardUuid,
    "css": cssUrl,
    "account_token": accountToken, // Only required if one or more end-users have been enrolled
    "target_origin": targetOrigin, // only required if you want to postMessage to the parent iframe
  }

  const embedRequestJson = JSON.stringify(queryParams, Object.keys(queryParams).sort());

  const embedRequest = new Buffer.from(embedRequestJson).toString("base64");

  const embedRequestHmac = hmacSignature(apiKey, embedRequestJson);

  return {
    "embed_request": embedRequest,
    "hmac": embedRequestHmac
  };
}

function getEmbedHtml(apiKey, cardUuid, cssUrl, accountToken = null) {
  const params = embedRequestQuery(apiKey, cardUuid, cssUrl, accountToken);

  const options = {
    headers: {
      "Accept": "text/html",
      "Authorization": `${apiKey}`
    },   
    hostname: "api.lithic.com",
    method: "GET",
    port: 443,
    path: "/v1/embed/card?" + new URLSearchParams(params)
  };

  const req = https.get(options, res => {
    console.log(`statusCode: ${res.statusCode}`);
  
    res.on("data", d => {
      /* HTML output */
      process.stdout.write(d);
    })
  })
  
  req.on("error", error => {
    console.error(error)
  })
  
  req.end();

  return req;
}
/** Crates:
base64 = "0.13.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hmac = "0.12.1"
*/

use base64;
use hmac::{Hmac, Mac};
use reqwest;
use serde::Serialize;
use serde_json;
use sha2::Sha256;
use std::env;

fn sort_alphabetically<T: Serialize, S: serde::Serializer>(
    value: &T,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    let value = serde_json::to_value(value).map_err(serde::ser::Error::custom)?;
    value.serialize(serializer)
}

#[derive(Serialize)]
struct SortAlphabetically<T: Serialize>(#[serde(serialize_with = "sort_alphabetically")] T);

#[derive(Serialize)]
struct EmbedRequestParams<'a> {
    token: &'a str,
    css: &'a str,
    account_token: &'a str,
    target_origin: &'a str,
}

fn hmac_signature(key: &str, msg: &str) -> String {
    type HmacSha256 = Hmac<Sha256>;

    let mut mac = HmacSha256::new_from_slice(key.as_bytes()).unwrap();
    mac.update(&msg.as_bytes());

    let code_bytes = mac.finalize().into_bytes();

    return base64::encode(&code_bytes.to_vec());
}

fn embed_request_query(
    api_key: &str,
    card_token: &str,
    css_url: &str,
    account_token: &str,
    target_origin: &str,
) -> (String, String) {
    let params = EmbedRequestParams {
        token: card_token,
        css: css_url,
        account_token: account_token,
        target_origin: target_origin,
    };

    // Embed request params must be sorted alphabetically
    let embed_request_json = serde_json::to_string(&SortAlphabetically(&params)).unwrap();

    let embed_request = base64::encode(&embed_request_json);
  
    // Generate HMAC digest
    let hmac = hmac_signature(&api_key, &embed_request_json);

    return (embed_request, hmac);
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = env::args().collect();

    let api_key = &args[1];
    let card_token = &args[2];
    let css_url = &args[3];
    let account_token = &args[4];
    let target_origin = &args[5];

    let client = reqwest::Client::new();
  
    // Generate query parameters by encoding and signing embed request
    let (embed_request, hmac) = embed_request_query(
        api_key,
        card_token,
        css_url,
        account_token,
        target_origin,
    );

    // Get HTML
    let resp = client
        .get("https://api.lithic.com/v1/embed/card")
        .query(&[("embed_request", &embed_request), ("hmac", &hmac)])
        .header("Authorization", format!("{}", api_key))
        .send()
        .await?
        .text()
        .await?;

    println!("{}", resp);

    Ok(())
}

Styling Your Card

As mentioned above, you can provide your own CSS URL in the request to style your card. Below is an example CSS stylesheet for formatting your card, including a visual indication that copying is happening on-click.

#card {
  border-radius: 16px;
  box-shadow: 0 10px 20px rgb(40 51 75 / 20%);
  box-sizing: border-box;
  background-color: #ff2d36;
  /* Add your logo as a background image */
  background-image: url("https://lithic.com/sterling/img/logo-black.4793a8fe.svg");
  background-repeat: no-repeat;
  /* background-position will need adjusting depending on your logo */
  background-position: bottom 150px left 190px;
  color: #151418;
  display: flex;
  flex-direction: column;
  font-family: "Roboto Mono", sans-serif;
  font-size: 16px;
  height: 12.61rem; 
  justify-content: space-between;
  line-height: 24px;
  margin: 40px auto;
  overflow: hidden;
  padding: 24px;
  position: relative;
  user-select: none;
  width: 20rem;
}

.pan-separator {
  margin: 6px;
}

#pan {
  border-radius: 6px;
  bottom: 65px;
  cursor: pointer;
  display: flex;
  flex-direction: row;
  font-size: 16px;
  font-weight: 500;
  height: 32px;
  justify-content: center;
  left: 14px;
  letter-spacing: 6px;
  line-height: 30px;
  padding: 2px 10px 0;
  position: absolute;
}

#expiry {
  border-radius: 6px;
  bottom: 24px;
  font-size: 16;
  font-weight: 400;
  left: 20px;
  line-height: 30px;
  opacity: 0.8;
  padding: 2px 4px 0;
  position: absolute;
}

#month, #year {
  border-radius: 6px;
  cursor: pointer;
  line-height: 30px;
  padding: 4px 1px;
}

#cvv {
  border-radius: 6px;
  bottom: 24px;
  cursor: pointer;
  font-size: 16;
  font-weight: 400;
  left: 92px;
  line-height: 30px;
  margin-left: 25px;
  opacity: 0.8;
  padding: 2px 4px 0;
  position: absolute;
}

#expiry::before {
  content: 'EXP ';
}

#cvv::before {
  content: 'CVV ';
}

#cvv:hover, #pan:hover, #month:hover, #year:hover  {
  background-color:rgba(0, 0, 0, 0.1);
}

#cvv:active, #pan:active, #month:active, #year:active {
  background-color:rgba(0, 0, 0, 0.05);
}

#alert {
  display: none;
}
Example card rendering with logoExample card rendering with logo

Example card rendering with logo

Putting It All Together

To quickly test your card out locally, you can serve your CSS stylesheet and the generated HTML in a directory.

First, generate HTML of the rendered card and save it in a directory called LithicDemo:

API_KEY = "YOUR_API_KEY" 
CARD_TOKEN = "EXAMPLE_CARD_TOKEN"
CSS_URL = "http://localhost:8080/your_card_style.css"

html = get_embed_html(API_KEY, CARD_TOKEN, CSS_URL)

# Write your HTML to a directory called "LithicDemo"
with open("path/to/LithicDemo/card.html", "w") as f:
    f.write(html)

Serve the assets in LithicDemo:

$ cd LithicDemo/  # directory containing your stylesheet and card.html
$ python -m  http.server 8080  # serve the directory locally on port 8080

Now go to http://localhost:8080/card.html to view your card.

As mentioned above, in your customer-facing application, you should embed the html as an iframe. Make sure to allow clipboard-write if you want users to be able to copy the card details.

If you supply target_origin in the embed request, you can also capture click events in the parent iframe by adding an event listener.

<html>

<head></head>

<body>
    <iframe
        id="card-iframe"
        allow="clipboard-write"
        width=600 height=300
        src="http://localhost:8080/output.html">
    </iframe>
    <script>
        window.addEventListener("message",
            function (e) {
                console.log("event", e);
                if (e.origin !== 'http://localhost:8080') return;

                if (e.data.isCopied === true) {
                    alert(e.data.copyElt + " copied!");
                }
            },
            false);
    </script>
</body>

</html>

Did this page help you?