Coinme Vault SDK for iOS Native
Introduction
The Coinme Vault SDK for iOS is a Swift SDK that wraps the VGS Collect iOS SDK, pre-binds Coinme's VGS vault configuration, and exposes a small, secure data-collection API. It lets your application securely collect sensitive card and payment-method data (card number, expiry, CVV, cardholder name, billing address) and submit it directly to Coinme's vault without that data ever entering your application process.
It is the iOS counterpart to the Coinme Vault SDK for Android, and the public API mirrors the Android SDK for cross-platform parity.
Security model: Secure field values (card number, CVV, …) never leave the VGS field views and never enter your application process. The SDK exposes only validity and non-sensitive metadata (isValid, isEmpty, cardBrand, last4, …). Submission goes directly from the VGS views to the vault. Error messages never contain field values (PII).
What Does It Collect?
The SDK collects only the payment data you explicitly bind into its fields, and submits it directly to the Coinme vault. The supported fields are:
- Card data — card number, expiry month/year, CVV
- Cardholder name
- Billing address — address lines, city, ZIP code
Your application never receives the raw field values — it sees only validity state and non-sensitive metadata such as the detected card brand and the last four digits.
SDK Requirements
| Requirement | Value |
|---|---|
| Minimum iOS | 15.0 |
| Swift | 5.9 |
| VGS Collect | ~> 1.18 (resolves transitively) |
VGS Collect is resolved as a normal dependency, so the VGS field-view classes (VGSCardTextField, …) are available on your compile classpath without an extra dependency.
Installation
IMPORTANT: Contact your Coinme integration team to obtain your Cloudsmith entitlement token. The library publishes to Cloudsmith (coinme/coinme-sdk-mobile) for both Swift Package Manager and CocoaPods.
Swift Package Manager
One-time registry login (uses your Cloudsmith entitlement token):
swift package-registry set https://swift.cloudsmith.io/coinme/coinme-sdk-mobile/swift package-registry login https://swift.cloudsmith.io/coinme/coinme-sdk-mobile/ \ --username YOUR_USERNAME --password YOUR_ENTITLEMENT_TOKENThen add the dependency:
// Package.swift
.package(id: "coinme.CoinmeVaultSDK", from: "1.0.0")The SDK pulls VGSCollectSDK via SCM. To resolve it through the registry instead, run:
swift package --replace-scm-with-registry resolveTo add the package in Xcode:
File → Add Package Dependencies, add the registry URL above, then search for coinme.CoinmeVaultSDK.
CocoaPods
# Podfile — <ENTITLEMENT_TOKEN> supplied by Coinme
source 'https://dl.cloudsmith.io/<ENTITLEMENT_TOKEN>/coinme/coinme-sdk-mobile/cocoapods/index.git'
source 'https://cdn.cocoapods.org/'
target 'YourApp' do
use_frameworks!
pod 'CoinmeVaultSDK', '~> 1.0' # pulls VGSCollectSDK transitively
endConfiguration
The account-scoped vault ID is baked into the library per environment at publish time and is never passed by callers. For local development, copy local.properties.example to local.properties and fill in:
COINME_VAULT_ID_SANDBOX=your_sandbox_vault_id
COINME_VAULT_ID_LIVE=your_live_vault_idIngress hostnames and the submit path are not configured here — they are public, stable values baked into the SDK per environment (config/VaultDefaults.swift): sandbox → secure.payments-stage.coinme.com, live → secure.payments.coinme.com, path → /caas/v2/paymentmethods. They can be overridden per-runtime (but never the vault ID) via RuntimeVaultConfig — see Runtime configuration overrides below.
Quick Start
import CoinmeVaultSDK
// 1. Create the vault for an environment.
let vault = try CoinmeVault.create(environment: .sandbox)
// 2. Build pre-named VGS fields against the vault's collector and bind them.
let cardNumber = CoinmeFields.cardNumber(collector: vault.collector)
let expiry = CoinmeFields.expiry(collector: vault.collector)
let cvc = CoinmeFields.cvc(collector: vault.collector)
let holder = CoinmeFields.cardHolder(collector: vault.collector)
vault.bind(cardNumber, expiry, cvc, holder) // add the views to your view hierarchy too
// 3. Observe form validity (drive your submit button's enabled state).
vault.onFormStateChange = { state in submitButton.isEnabled = state.isValid }
// 4. Submit. Throws CoinmeVaultError on failure.
let result = try await vault.submit(
VaultSubmitOptions(
extraData: ["accountId": "abc", "country": "US"],
headers: ["X-Request-Source": "ios"]
)
)
print("status=\(result.status) body=\(result.body ?? "")")
// 5. Release VGS resources when done (e.g. on screen teardown).
vault.dispose()CoinmeVault is @MainActor — create it, build fields, bind, and observe state on the main actor.
Core Concepts
| Type | Role |
|---|---|
CoinmeVault | Entry point. Wraps a VGSCollect collector, tracks form validity, performs submits. @MainActor. |
CoinmeVaultEnvironment | .sandbox or .live. Selects the baked-in vault ID / ingress / path. |
CoinmeFields | Factory that builds VGS text fields with the correct Coinme field name and VGS type pre-set. |
VaultFieldNames | The dot-notation field-name constants VGS deep-merges into nested submit JSON. |
VaultSubmitOptions | Per-submit extraData (non-secure JSON) and headers. |
VaultSubmitResult | status: Int, body: String?, data: Any? on success. |
CoinmeVaultError | Error enum thrown by submit. |
The collector is exposed as vault.collector: VGSCollect — both the input to the CoinmeFields factories and the documented escape hatch for advanced VGS usage not covered by the wrapper.
Creating a vault
let vault = try CoinmeVault.create(
environment: .sandbox,
config: nil // optional RuntimeVaultConfig; see "Runtime configuration overrides"
)create() resolves the vault configuration, builds the underlying VGSCollect, and registers a single form-state listener. It throws CoinmeVaultError.invalidConfiguration if the vault ID is not baked in or an override is incomplete. Create one CoinmeVault per form/screen and dispose() it when the screen goes away.
Field names
Field names are dot-notation keys that VGS deep-merges into nested submit JSON (card.cardNumber → {"card":{"cardNumber":...}}). They are defined in VaultFieldNames:
| Constant | Value | Notes |
|---|---|---|
cardNumber | card.cardNumber | |
expMonth | card.month | child of the expiry serializer |
expYear | card.year | child of the expiry serializer |
cvc | card.cvv | |
cardHolder | cardHolderName | |
address1 | billingAddress.addressLine1 | |
address2 | billingAddress.addressLine2 | |
city | billingAddress.city | |
zipCode | billingAddress.zipCode | |
expiry | card | parent field name for the expiry field |
The SDK submits every bound field. Bind only the fields you intend to send.
The expiry field is the special case: its own field name is card and it uses a VGSExpDateSeparateSerializer(monthFieldName: "card.month", yearFieldName: "card.year") so a single input writes two JSON keys. The year is forced to 4 digits on output. CoinmeFields.expiry() configures all of this for you.
Building Fields
VGS field views subscribe to their collector the moment their configuration is set, so each CoinmeFields factory takes the owning vault's collector:
import CoinmeVaultSDK
let cardNumber = CoinmeFields.cardNumber(collector: vault.collector) // VGSCardTextField, "card.cardNumber"
let expiry = CoinmeFields.expiry(collector: vault.collector) // VGSExpDateTextField, separate-date serializer
let cvc = CoinmeFields.cvc(collector: vault.collector) // VGSCVCTextField, "card.cvv"
let holder = CoinmeFields.cardHolder(collector: vault.collector) // VGSTextField, "cardHolderName"
let custom = CoinmeFields.text(collector: vault.collector, fieldName: "billingAddress.city")cardNumber, cvc, and cardHolder accept an optional fieldName override; expiry does not (its serializer is fixed); text requires the field name. Returned views are ordinary VGSTextField subclasses — style them, add them to your view hierarchy, and vault.bind(...) them.
Rendering VGS fields
VGS fields are UIKit UIViews. In UIKit, add them to your view hierarchy directly. In SwiftUI, wrap each field in a UIViewRepresentable (the demo's VGSFieldView is the canonical reference):
struct VGSFieldView: UIViewRepresentable {
let field: VGSTextField
func makeUIView(context: Context) -> VGSTextField { field }
func updateUIView(_ uiView: VGSTextField, context: Context) {}
}Build the fields once (e.g. in your view model) so they aren't recreated on every SwiftUI render, then render each with VGSFieldView(field: cardNumber). See the Example/ app for a full working form.
Binding fields
func bind(_ fields: VGSTextField...) // required fields
func bindOptional(_ fields: VGSTextField...) // optional fieldsbind registers each field name with the form-validity tracker. Registration seeds the field as invalid — a freshly bound form reports isValid == false until VGS emits its first state change. Bind a field once.
VGS emits no initial state, so an untouched field's validity is unknown until the user edits it. Use bindOptional for fields that may be left empty (e.g. billingAddress.addressLine2): they seed as valid, so an untouched form can still reach isValid == true. Fields with no configured fieldName are skipped by bind/bindOptional — always build them via CoinmeFields first.
Observing Form State
var formState: VaultFormState // current snapshot
var formStatePublisher: AnyPublisher<VaultFormState, Never> // Combine stream
var onFormStateChange: ((VaultFormState) -> Void)? // non-Combine callback
func fieldState(_ fieldName: String) -> VaultFieldState? // one field
public struct VaultFormState: Equatable {
public let fields: [String: VaultFieldState]
public let isValid: Bool // true only when every bound field is valid
}
public struct VaultFieldState: Equatable {
public let fieldName: String
public let isValid: Bool
public let isEmpty: Bool
public let isFocused: Bool
public let contentLength: Int
public let cardBrand: String? // card-number field only
public let last4: String? // card-number field only
}Typical use — drive the submit button:
// Callback style:
vault.onFormStateChange = { state in submitButton.isEnabled = state.isValid }
// Combine style:
vault.formStatePublisher
.map(\.isValid)
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: submitButton)
.store(in: &cancellables)
// React to a single field, e.g. show the detected card brand:
let brand = vault.fieldState(VaultFieldNames.cardNumber)?.cardBrandNo secure values are ever present in these snapshots — only validity and the non-sensitive metadata shown above.
Submitting the Data
@discardableResult
func submit(_ options: VaultSubmitOptions = VaultSubmitOptions()) async throws -> VaultSubmitResult
public struct VaultSubmitOptions {
public let extraData: [String: Any] // deep-merged into the submit JSON alongside fields
public let headers: [String: String] // merged with trace headers; caller wins on conflict
}What submit does, in order:
- Pre-validates from the tracker; if the form is invalid it throws
CoinmeVaultError.validation(fields:)before any network call. - Generates per-request
X-B3-TraceId/X-B3-SpanIdand merges yourheaderson top (your headers win on conflict). - POSTs to the configured submit path with
extraDataas custom data via VGS. - Maps the response — including the CAAS envelope case where VGS returns HTTP 200 with a nested
errorResponsepayload (thrown asCoinmeVaultError.caas, not returned as success).
Only one submit may be in flight at a time — a concurrent call throws CoinmeVaultError.submitInProgress.
Completion-handler variant
vault.submit(VaultSubmitOptions()) { (result: Result<VaultSubmitResult, Error>) in
switch result {
case .success(let r): // ...
case .failure(let e): // ...
}
}Payment-method convenience
A higher-level helper for the CAAS payment-method endpoint:
let result = try await vault.submitPaymentMethod(
PaymentMethodInput(
accountId: "abc",
providerId: "prov-1",
webSessionId: "sess-1",
paymentProcessAssociation: .buy,
// optional billingAddress* fields default to ""/omitted; country defaults to "US"
billingAddressFirstName: "Jane",
billingAddressLastName: "Doe",
billingAddressState: "WA"
),
headers: ["Authorization": "Bearer \(token)"]
)It builds the CAAS extraData shape (accountId, stagingProviderId, webSessionId, paymentProcessAssociation, billingAddress) and delegates to submit. The card fields themselves are supplied securely by the bound VGS fields.
Error Handling
submit only ever throws CoinmeVaultError
do {
try await vault.submit(options)
} catch let error as CoinmeVaultError {
switch error {
case .validation(let fields):
fields.forEach { name, reasons in showFieldError(name, reasons) }
case .caas:
switch error.submitCardError {
case .cardInUseByAnotherUser: showCardInUse()
case .maxNumberOfCardsReached: showTooManyCards()
default: showGenericError()
}
case .server(let status, _):
showServerError(status)
default:
showGenericError()
}
}| Case | Meaning | Associated values |
|---|---|---|
.validation(fields:) | Form invalid; thrown before any network call. | [String: [String]] — ["required"] / ["invalid"] per field |
.network(underlying:) | Connectivity / transport failure. | Error? |
.server(status:body:) | HTTP 4xx/5xx with no recognizable CAAS envelope. | Int, String? |
.caas(status:errorResponse:) | CAAS error envelope (4xx/5xx, or nested in an HTTP 200). | Int, CaasErrorResponse |
.submitInProgress | A submit is already in flight. | — |
.invalidConfiguration(_:) | Missing/invalid configuration (e.g. an unbaked vault ID). | String |
.unknown(underlying:) | Anything else. | Error? |
error.submitCardError is a convenience on CoinmeVaultError that resolves the first known CAAS error code to a SubmitCardError (or nil if unrecognized). The raw code remains available via the associated CaasErrorResponse (errorResponse.firstKnownError()?.errorCode). SubmitCardError maps known CAAS card-submit codes to enum constants and is ported from the Coinme app to ease migration.
Error cases never carry a field value (PCI). Log them freely.
Runtime Configuration Overrides
The vault ID is always baked in at publish time and cannot be overridden. The ingress host and submit path can be overridden at runtime by passing a RuntimeVaultConfig to create:
let runtime = RuntimeVaultConfig(
sandbox: EnvironmentOverride(ingress: "tnt-xyz.sandbox.verygoodproxy.com", path: "/caas/v2/paymentmethods"),
live: EnvironmentOverride(ingress: "tnt-xyz.live.verygoodproxy.com", path: "/caas/v2/paymentmethods")
)
let vault = try CoinmeVault.create(environment: .sandbox, config: runtime)When config is nil, the baked-in defaults are used: a blank ingress means the VGS default host, and the path defaults to /caas/v2/paymentmethods.
Lifecycle
Call vault.dispose() when the form is gone. It stops state observation and unsubscribes all VGS text fields to release resources. After disposal the instance must not be reused — create a new one.
In SwiftUI, tie disposal to the view's lifetime (e.g. call dispose() from .onDisappear on the view model that owns the vault).
Updated 18 days ago
