Skip to main content

Dyneti Checkout Form Builder (Mobile)

Native checkout fields with a built-in DyScan card scanner. Card details are scanned or typed into fields owned by the SDK and sent straight from the device to your vault. Your app receives a token and never receives the raw card values.

How it works​

  1. Vault integration. You choose where tokens are created by passing a vault config such as BasisTheoryConfig. Every supported vault is included in the SDK. See Vault integrations.
  2. Fields. You create the card fields you need (card number, expiry, CVC, name) and place them in your own layout. The card number field always has a button that opens the scanner.
  3. Orchestrator. The DyScanCheckout object validates every attached field as the user types, lets the scanner fill them, tells your app when the form is ready to submit, and tokenizes on submit.
info

The SDK includes DyScan and every supported vault integration. Do not add the DyScan pod or package to your app separately.

iOS​

Requirements​

  • iOS 15 or later
  • Xcode with Swift 5.9 or later. The SDK includes the universal build of DyScan, so there is no Swift-version variant to choose.
  • A DyScan API key
  • An account with a supported vault (see Vault integrations)
  • The access token for the Dyneti package repository

Contact Dyneti to get a DyScan API key and the access token.

Installation​

CocoaPods​

Add the pod to your Podfile with the Dyneti repository URL. Your app's minimum iOS version must be 15 or later. use_frameworks! is required.

platform :ios, '15.0'
use_frameworks!

target 'YourApp' do
pod 'DyScanCheckout',
:git => 'https://dyscan@github.com/Dyneti/dyscan-checkout-ios-distribution.git'
end

You do not add a source line: the pod comes directly from the repository URL. Without a :tag, CocoaPods installs the latest release, and pod update DyScanCheckout moves to newer releases later. To pin a version, add :tag => '1.0.0' (or the version you want). Then run:

$ pod install

When asked for a password for user dyscan, paste the access token that Dyneti provided.

danger

If 2FA is set up on your GitHub account, this step may fail. In that case, generate a personal access token in GitHub (Settings > Developer settings > Personal access tokens) and use it as the password.

Swift Package Manager​

  1. In Xcode choose File > Add Package Dependencies.

  2. Enter this URL:

    https://dyscan@github.com/Dyneti/dyscan-checkout-ios-distribution.git
  3. When asked for a password for user dyscan, enter your access token.

  4. Choose the dependency rule Up to Next Major Version.

  5. Add the DyScanCheckout product to your app target.

Camera permission​

Add NSCameraUsageDescription (Privacy - Camera Usage Description) to your Info.plist if your app does not already ask for camera permission. The value is the text shown to the user, for example "To scan your credit card".

Quick start​

Every integration has the same shape. Only the vault config changes. DyScanCheckout and the fields are @MainActor: call them from the main thread (the compiler enforces this). Callbacks are delivered on the main thread.

import UIKit
import DyScanCheckout

final class CheckoutViewController: UIViewController {
private let checkout = DyScanCheckout(
dyscanApiKey: "<DYSCAN_API_KEY>",
backend: BasisTheoryConfig(btApiKey: "<BASIS_THEORY_PUBLIC_KEY>") // your vault's config
)

private let number = CardNumberField()
private let expiry = ExpiryField()
private let cvc = CvcField()
private let payButton = UIButton(type: .system)

override func viewDidLoad() {
super.viewDidLoad()
checkout.presenter = self // used to present the scanner

payButton.setTitle("Pay", for: .normal)
payButton.isEnabled = false
payButton.addTarget(self, action: #selector(pay), for: .touchUpInside)

// Place the fields in your own layout, then attach them to the checkout.
let row = UIStackView(arrangedSubviews: [expiry, cvc])
row.spacing = 16; row.distribution = .fillEqually
let stack = UIStackView(arrangedSubviews: [number, row, payButton])
stack.axis = .vertical; stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
])
checkout.attach(number, expiry, cvc)

checkout.onStateChange = { [weak self] state in
self?.payButton.isEnabled = state.canSubmit // true when every attached field is filled and correct
}
}

@objc private func pay() {
Task {
do {
let token = try await checkout.submit() // runs on the main actor
print(token.token) // send this to your backend
} catch {
print(error)
}
}
}
}

The SDK is built with UIKit. SwiftUI apps can host its views with UIViewRepresentable.

Building your form​

The form is the fields you create. Each field is a UIView that you place anywhere in your own layout, in code, a storyboard or a XIB. Include only the ones you need, then attach them to the checkout. Whatever you attach is what the SDK validates and submits.

let checkout = DyScanCheckout(dyscanApiKey: "<DYSCAN_API_KEY>", backend: <your vault config>)

let number = CardNumberField() // card number only
myOwnStackView.addArrangedSubview(number)
checkout.attach(number)

Combine as many as you like. attach accepts several fields, or an array:

checkout.attach(number, expiry, cvc)
checkout.attach([name, number, expiry, cvc])
checkout.detach(name) // stop including a field (it is cleared)

Rules:

  • The card number is always required. Attach a CardNumberField in every form. Your vault may require more fields (see Vault integrations). submit fails with .missingRequiredFields until they are attached.
  • A checkout holds one field of each type. Attaching a second field of the same type replaces the first, which is cleared.
  • The checkout holds fields weakly. A field must be part of your view hierarchy (or otherwise kept by your code) to stay attached.
  • A field can move from one checkout to another by attaching it to the new one. The field is cleared when it moves.
  • Fields you do not attach are not validated and are not sent. Scanning, validation and form state work the same with any subset of fields that includes the card number.

You cannot read a field's value. A field exposes only its state (field.state).

Field types​

ClassNotes
CardNumberFieldShows the card network logo on the left and the scan button on the right. Formats as you type.
ExpiryFieldShown as MM / YY. With fourDigitYear: true, MM / YYYY.
CvcFieldUp to 4 digits for American Express, 3 digits for other networks. Until the card network is known, 3 or 4 digits are valid.
NameFieldCardholder name.

Field options​

Options are parameters of each field's initializer:

CardNumberField(placeholder: "Card number")
ExpiryField(fourDigitYear: true) // MM / YYYY instead of MM / YY
CvcField(style: myStyle)
CardNumberField(firstSixPattern: "^(4|5)") // the first six digits must match a regular expression
OptionApplies toMeaning
placeholderallPlaceholder text. Can also be changed later with field.placeholder.
styleallA FieldStyle (see Styling). Can also be changed later with field.style.
fourDigitYearExpiryFieldAccept and show a four-digit year. Can also be set later with expiryField.fourDigitYear (the field is cleared when it changes).
firstSixPatternCardNumberFieldRegular expression the first six digits must match. Can also be set later with numberField.firstSixPattern.

The orchestrator: validation and submit readiness​

DyScanCheckout validates each attached field as the user types and tells your app when the whole form is ready. onStateChange receives a FormState on every change. It never contains field values.

PropertyMeaning
canSubmitThe card number and the fields your vault requires are attached, every attached field is filled and correct, and nothing is being submitted or already submitted. Use this to enable your pay button.
validAt least one field is attached and every attached field is filled and correct.
issuingNetworkThe detected card network.
fields[type]A FieldState: isEmpty, isValid, isFocused, touched, dirty, length, brand.
focusedThe field that has focus, if any.
eventThe last interaction (focus, blur, or a tap on the scan button).

touched means the user focused the field and left it. dirty means the user changed its value. checkout.canSubmit, checkout.isValid and checkout.invalidFields give the same answers on demand.

submit refuses to run until the form is ready. It throws .missingRequiredFields([...]) if a needed field is not attached, and .invalidFields([...]) if some field is not valid. A failed submit keeps the field values so the user can retry.

Scanning​

The card number field has a camera button that starts a scan. You can also start one from your own button:

checkout.presenter = self                      // a UIViewController; required to present the scanner
checkout.scan { result in /* optional */ }

The SDK configures DyScan with your API key and presents the scanner for you. You do not call DyScanApp.configure or use DyScan's own view controllers. A scan fills the name and CVC only when those fields are attached.

Scan options are passed when you create the checkout. Each one has a default, so set only the ones you want to change:

var scan = ScanOptions()
scan.roundCorners = false
scan.lightTorchWhenDark = true

let checkout = DyScanCheckout(dyscanApiKey: "<DYSCAN_API_KEY>", backend: vault, scan: scan)

Styling​

Every field accepts a FieldStyle, in its initializer or later through field.style:

var style = FieldStyle()
style.fontSize = 17
style.fontColor = .label
style.backgroundColor = .systemBackground
style.placeholderFontColor = .secondaryLabel
style.borderColor = .separator
style.showBorder = true
style.fontFamily = "Avenir-Book"
style.height = 48
style.cornerRadius = 8
let cvc = CvcField(style: style)

Share one FieldStyle value across fields to make them match.

OptionDefaultMeaning
showBordertrueDraws a 1 point border around the field.
height40 ptMinimum height of the field.
cornerRadius4 ptCorner radius of the field.
fontSize, fontColor, fontFamily16 pt, adapts to dark mode, system fontText. Always scales with Dynamic Type, with a custom font family too.
placeholderFontColor, backgroundColor, borderColoradapt to dark modeField colors.
highlightBorderfalseBorder turns validColor as soon as the value is correct. It turns invalidColor when the user leaves the field with an incorrect value.
highlightTextfalseText turns validColor as soon as the value is correct. It turns invalidColor when the user leaves the field with an incorrect value.
validColor, invalidColorgreen, red (adapt to dark mode)Colors used by the two highlight options.

The container around the fields (background, padding, spacing) is your own layout.

Defaults adapt to light and dark mode. Each field has a VoiceOver label ("Card number", "Expiration date", "Security code", "Name on card"), the card field announces the detected network, and the scan button is labeled "Scan card".

Submitting​

let token = try await checkout.submit()
// or
checkout.submit { result in ... }

On success you receive a TokenPayload:

PropertyMeaning
tokenThe vault's token. Send it to your backend.
providerNameWhich vault created it, for example "basis_theory".
metadataNon-sensitive details the vault returned (see your vault's section).
formIdThe id of the form that produced the token. Optional.
firstSixThe first six digits of the card number. Optional.
lastFourThe last four digits of the card number. Optional.
expMonthExpiry month, two digits. Optional: nil when no expiry field is attached.
expYearExpiry year, four digits. Optional: nil when no expiry field is attached.

On success the SDK clears every field. A form that has produced a token cannot be submitted again until you call reset().

checkout.reset()                 // clears the fields and starts a new form (new formId)
checkout.wipeSensitiveData() // clears the fields only

Errors​

ErrorMeaning
.invalidFields([FieldType])Some attached fields are not valid. The list says which.
.missingRequiredFields([FieldType])The card number, or a field your vault needs, is not attached.
.alreadySubmittedThis form already produced a token. Call reset() to start a new one.
.submitInProgressA request is already running.
.scannerUnavailableThe scanner could not start, for example because no presenter was set or the presenter is already showing another screen.
.scanCancelledThe user closed the scanner.
.scanFailed(String)The scanner reported an error (camera error, no permission, authentication error).
.scanInProgressA scan is already open.
.provider(code, message)The vault rejected the request. code is auth, validation, rateLimited, network, server or unknown. The message never contains card values.

Vault integrations​

Every supported vault is included in the SDK. You do not install anything extra: you choose a vault by passing its config when you create the checkout. Each section below lists the vault's keys, its configuration, the fields it needs, the token details it returns, and its own notes.

Integrating with Basis Theory​

Prerequisites​
  • A Basis Theory tenant. Use a test tenant while you integrate, with test card numbers such as 4242 4242 4242 4242.
  • A public application key with only the token:create permission. Never put a private or management key in an app. The SDK refuses keys that look private or management.
Setup​

Pass BasisTheoryConfig as the backend:

import DyScanCheckout

let checkout = DyScanCheckout(
dyscanApiKey: "<DYSCAN_API_KEY>",
backend: BasisTheoryConfig(btApiKey: "<BASIS_THEORY_PUBLIC_KEY>"))
Options​
OptionDefaultMeaning
btApiKeynone (required)Public application key (token:create only).
requiredFieldsnumber, expiry, CVCFields that must be attached before the form can be submitted. The card number is always required.
maxAttempts3Attempts for timeouts, network errors and temporary server errors (HTTP 408, 429, 500, 502, 503, 504).
timeout30 sRequest timeout.
Fields​

Basis Theory card tokens use the number, expiry and CVC fields, the same as the web form builder. If one is not attached, submit fails with .missingRequiredFields. The cardholder name is not sent to Basis Theory.

To collect only the card number, set requiredFields to the number alone and attach only the number field:

var vault = BasisTheoryConfig(btApiKey: "<BASIS_THEORY_PUBLIC_KEY>")
vault.requiredFields = [.number]
let checkout = DyScanCheckout(dyscanApiKey: "<DYSCAN_API_KEY>", backend: vault)

let number = CardNumberField()
myOwnStackView.addArrangedSubview(number)
checkout.attach(number)
Result​

token is the Basis Theory token id. metadata has last4, bin, brand, funding, expiration_month and expiration_year.

Errors specific to Basis Theory​
ErrorUsually means
.provider(.auth, ...)The key is wrong, lacks token:create, or looks like a private key.
.provider(.validation, ...)Basis Theory rejected a field. The message names the fields, never their values.
.provider(.rateLimited, ...)Too many requests for this key. The SDK retries before returning this.
.provider(.network, ...)The request could not reach Basis Theory after the allowed attempts.
Notes​
  • Cards are sent to Basis Theory over TLS directly from the device. Your servers never receive card data.
  • Requests use an ephemeral URL session (no cookies, no cache).
  • Retries after a timeout can create a duplicate token that your backend never uses.

Integrating with another vault​

Contact Dyneti if the vault you tokenize with is not listed above.

Configuration reference​

ParameterDefaultMeaning
dyscanApiKeynoneDyScan API key.
backendnoneThe vault config, for example BasisTheoryConfig.
scanscanner defaultsScanOptions (see Scanning).
loggerCheckoutLogger() (no sinks, so nothing is output)Optional. Add a sink such as ConsoleLogSink() to receive structured SDK events. They never contain card values.

DyScanCheckout members

MemberMeaning
attach(_:), detach(_:)Add fields to, or remove them from, the form. Accept several fields or an array.
presenterThe view controller used to present the scanner.
onStateChangeCalled with a FormState on every change.
canSubmit, isValid, invalidFields, missingRequiredFieldsCurrent readiness.
scan(completion:)Starts the scanner.
submit(), submit(completion:)Validates and tokenizes (async, or with a callback).
reset(), wipeSensitiveData()Start a new form, or only clear the fields.
formId, hasSubmittedThe current form id, and whether it already produced a token.

Android​

Native checkout fields with a built-in DyScan card scanner, with the same concepts, input rules, styling options, errors, token result and protections as the iOS SDK. The sections below cover what differs on Android.

Requirements​

  • Android API level 21 or later. Autofill hints need Android 8.0 (API 26) and are ignored below it.
  • Java 17 language level
  • An AndroidX ComponentActivity (for example AppCompatActivity) as the screen that hosts the form
  • A DyScan API key
  • An account with a supported vault (see Vault integrations)
  • Nexus credentials

Contact Dyneti to get a DyScan API key and Nexus credentials.

Installation​

In your project's settings.gradle, add the Dyneti Maven repository:

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven {
credentials { username = "nexusUsername"; password = "nexusPassword" }
url = uri("https://nexus.dyneti.com/repository/maven-releases/")
authentication { basic(BasicAuthentication) }
}
}
}

Replace nexusUsername and nexusPassword with your Nexus credentials. Then add the dependency to your app module's build.gradle. There is one artifact:

dependencies {
implementation "com.dyneti.android.checkout:dyscan-checkout:1.+"
}

1.+ installs the newest 1.x release each time Gradle resolves dependencies. To pin a version, use an exact one such as 1.0.0.

The checkout library depends on DyScan, so Gradle downloads it from the same repository. Do not add com.dyneti.android.dyscan:dyscan yourself. If your app already declares it, remove that line, or keep it at exactly the version the checkout library uses. When two versions are declared, Gradle uses the higher one.

The DyScan library ships its own ProGuard / R8 rules, so you do not need to add any.

Permissions​

You do not need to declare any permission. The scanner's manifest declares camera, internet, vibrate and flashlight permissions, and Gradle merges them into your app. The scanner asks the user for camera permission the first time a scan starts.

Quick start​

Create the checkout in onCreate, before the activity starts. Call the SDK and read its properties from the main thread (the methods are annotated @MainThread). Callbacks are delivered on the main thread. The fields are views that you add in your layout XML or in code, then attach to the checkout.

<com.dyneti.android.checkout.CardNumberField android:id="@+id/number" android:layout_width="match_parent" android:layout_height="wrap_content"/>
<com.dyneti.android.checkout.ExpiryField android:id="@+id/expiry" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1"/>
<com.dyneti.android.checkout.CvcField android:id="@+id/cvc" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1"/>
class CheckoutActivity : AppCompatActivity() {
private lateinit var checkout: DyScanCheckout

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_checkout)

checkout = DyScanCheckout(
context = this,
dyscanApiKey = "<DYSCAN_API_KEY>",
backend = BasisTheoryConfig(btApiKey = "<BASIS_THEORY_PUBLIC_KEY>"),
)
checkout.activity = this // used to launch the scanner

checkout.attach(
findViewById<CardNumberField>(R.id.number),
findViewById<ExpiryField>(R.id.expiry),
findViewById<CvcField>(R.id.cvc),
)

checkout.onStateChange = { state -> payButton.isEnabled = state.canSubmit }
payButton.setOnClickListener {
lifecycleScope.launch {
try {
val token = checkout.submit() // suspend function, resumes on the main thread
sendToBackend(token.token)
} catch (e: CheckoutException) {
Log.w("checkout", e) // e.error is a CheckoutError
}
}
}
}
}

The attach rules (including that the card number is always required), field options (placeholder, style, fourDigitYear, firstSixPattern, set as XML attributes, constructor parameters or properties), FieldStyle options (including height and cornerRadius), readiness properties, and errors are the same as on iOS. A callback form, checkout.submit { result -> ... }, is also available.

Basis Theory on Android​

Basis Theory is included in the library, so there is nothing extra to add. Pass its config. The options, required fields, result and errors are the same as in Integrating with Basis Theory.

val checkout = DyScanCheckout(
context = this,
dyscanApiKey = "<DYSCAN_API_KEY>",
backend = BasisTheoryConfig(btApiKey = "<BASIS_THEORY_PUBLIC_KEY>"))

To collect only the card number:

val vault = BasisTheoryConfig(btApiKey = "<BASIS_THEORY_PUBLIC_KEY>", requiredFields = setOf(FieldType.Number))
val checkout = DyScanCheckout(context = this, dyscanApiKey = "<DYSCAN_API_KEY>", backend = vault)
checkout.attach(findViewById<CardNumberField>(R.id.number))