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
- 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. - 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.
- Orchestrator. The
DyScanCheckoutobject 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 onsubmit.
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.
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
-
In Xcode choose File > Add Package Dependencies.
-
Enter this URL:
https://dyscan@github.com/Dyneti/dyscan-checkout-ios-distribution.git -
When asked for a password for user
dyscan, enter your access token. -
Choose the dependency rule Up to Next Major Version.
-
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
CardNumberFieldin every form. Your vault may require more fields (see Vault integrations).submitfails with.missingRequiredFieldsuntil 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
| Class | Notes |
|---|---|
CardNumberField | Shows the card network logo on the left and the scan button on the right. Formats as you type. |
ExpiryField | Shown as MM / YY. With fourDigitYear: true, MM / YYYY. |
CvcField | Up to 4 digits for American Express, 3 digits for other networks. Until the card network is known, 3 or 4 digits are valid. |
NameField | Cardholder 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
| Option | Applies to | Meaning |
|---|---|---|
placeholder | all | Placeholder text. Can also be changed later with field.placeholder. |
style | all | A FieldStyle (see Styling). Can also be changed later with field.style. |
fourDigitYear | ExpiryField | Accept and show a four-digit year. Can also be set later with expiryField.fourDigitYear (the field is cleared when it changes). |
firstSixPattern | CardNumberField | Regular 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.
| Property | Meaning |
|---|---|
canSubmit | The 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. |
valid | At least one field is attached and every attached field is filled and correct. |
issuingNetwork | The detected card network. |
fields[type] | A FieldState: isEmpty, isValid, isFocused, touched, dirty, length, brand. |
focused | The field that has focus, if any. |
event | The 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.
| Option | Default | Meaning |
|---|---|---|
showBorder | true | Draws a 1 point border around the field. |
height | 40 pt | Minimum height of the field. |
cornerRadius | 4 pt | Corner radius of the field. |
fontSize, fontColor, fontFamily | 16 pt, adapts to dark mode, system font | Text. Always scales with Dynamic Type, with a custom font family too. |
placeholderFontColor, backgroundColor, borderColor | adapt to dark mode | Field colors. |
highlightBorder | false | Border turns validColor as soon as the value is correct. It turns invalidColor when the user leaves the field with an incorrect value. |
highlightText | false | Text turns validColor as soon as the value is correct. It turns invalidColor when the user leaves the field with an incorrect value. |
validColor, invalidColor | green, 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:
| Property | Meaning |
|---|---|
token | The vault's token. Send it to your backend. |
providerName | Which vault created it, for example "basis_theory". |
metadata | Non-sensitive details the vault returned (see your vault's section). |
formId | The id of the form that produced the token. Optional. |
firstSix | The first six digits of the card number. Optional. |
lastFour | The last four digits of the card number. Optional. |
expMonth | Expiry month, two digits. Optional: nil when no expiry field is attached. |
expYear | Expiry 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
| Error | Meaning |
|---|---|
.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. |
.alreadySubmitted | This form already produced a token. Call reset() to start a new one. |
.submitInProgress | A request is already running. |
.scannerUnavailable | The scanner could not start, for example because no presenter was set or the presenter is already showing another screen. |
.scanCancelled | The user closed the scanner. |
.scanFailed(String) | The scanner reported an error (camera error, no permission, authentication error). |
.scanInProgress | A 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:createpermission. 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
| Option | Default | Meaning |
|---|---|---|
btApiKey | none (required) | Public application key (token:create only). |
requiredFields | number, expiry, CVC | Fields that must be attached before the form can be submitted. The card number is always required. |
maxAttempts | 3 | Attempts for timeouts, network errors and temporary server errors (HTTP 408, 429, 500, 502, 503, 504). |
timeout | 30 s | Request 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
| Error | Usually 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
| Parameter | Default | Meaning |
|---|---|---|
dyscanApiKey | none | DyScan API key. |
backend | none | The vault config, for example BasisTheoryConfig. |
scan | scanner defaults | ScanOptions (see Scanning). |
logger | CheckoutLogger() (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
| Member | Meaning |
|---|---|
attach(_:), detach(_:) | Add fields to, or remove them from, the form. Accept several fields or an array. |
presenter | The view controller used to present the scanner. |
onStateChange | Called with a FormState on every change. |
canSubmit, isValid, invalidFields, missingRequiredFields | Current 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, hasSubmitted | The 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 exampleAppCompatActivity) 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))