Coinme Vault SDK for Android Native

Introduction

The Coinme Vault SDK is a Kotlin library for Android that wraps the VGS Collect Android 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 Android counterpart to the vault-sdk-react-native SDK, and the public API mirrors the React Native 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

RequirementValue
Minimum Android versionAndroid 7.0 (Nougat) — API Level 24
Compile SDK37
JVM target17
VGS Collect1.11.0 (re-exported as api)

The SDK ships no Compose dependency — VGS fields are Android Views, so "Compose support" is just AndroidView interop (see Integration path 3 below).



Installation

IMPORTANT: Contact your Coinme integration team to obtain your Cloudsmith entitlement token. The library publishes to Cloudsmith (coinme/coinme-sdk-mobile) as com.coinme:vault-sdk-android.

Step 1: Add the Repository

In settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.cloudsmith.io/coinme/coinme-sdk-mobile/")
            // Cloudsmith entitlement token, supplied by Coinme.
            credentials(HttpHeaderCredentials::class) {
                name = "Authorization"
                value = "Bearer ${providers.gradleProperty("CLOUDSMITH_ENTITLEMENT_TOKEN").get()}"
            }
            authentication { create<HttpHeaderAuthentication>("header") }
        }
    }
}

Step 2: Add the Dependency

In app/build.gradle.kts:

dependencies {
    implementation("com.coinme:vault-sdk-android:1.0.0")
}

Because the SDK declares api(vgscollect), the VGS field-view classes (VGSCardNumberEditText, …) are available on your compile classpath without an extra dependency.



Configuration

The account-scoped vault ID is baked into the library as a BuildConfig field, read from an environment variable with a local.properties fallback. Copy local.properties.example to local.properties and fill in the values, or set the env vars in CI:

COINME_VAULT_ID_SANDBOX=your_sandbox_vault_id
COINME_VAULT_ID_LIVE=your_live_vault_id

The env var names match the React Native SDK's gen-vault-config, so one .env serves both repos.

Ingress hostnames and the submit path are not configured here — they are public, stable values baked into the SDK per environment: 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 com.coinme.vaultsdk.CoinmeVault
import com.coinme.vaultsdk.CoinmeVaultEnvironment
import com.coinme.vaultsdk.core.VaultSubmitOptions
import com.coinme.vaultsdk.widget.CoinmeFields

// 1. Create the vault for an environment.
val vault = CoinmeVault.create(context, CoinmeVaultEnvironment.SANDBOX)

// 2. Build pre-named VGS fields and bind them.
val cardNumber = CoinmeFields.cardNumber(context)
val expiry     = CoinmeFields.expiry(context)
val cvc        = CoinmeFields.cvc(context)
val holder     = CoinmeFields.cardHolder(context)
vault.bind(cardNumber, expiry, cvc, holder)   // add the views to your layout too

// 3. Observe form validity (drive your submit button's enabled state).
lifecycleScope.launch {
    vault.formState.collect { state -> submitButton.isEnabled = state.isValid }
}

// 4. Submit. Throws CoinmeVaultException on failure.
lifecycleScope.launch {
    val result = vault.submit(
        VaultSubmitOptions(
            extraData = mapOf("accountId" to "abc", "country" to "US"),
            headers = mapOf("X-Request-Source" to "android"),
        )
    )
    println("status=${result.status} body=${result.body}")
}

// 5. Release VGS resources when done (e.g. onDestroy / onDispose).
vault.dispose()


Core Concepts

TypeRole
CoinmeVaultEntry point. Wraps a VGSCollect collector, tracks form validity, performs submits.
CoinmeVaultEnvironmentSANDBOX or LIVE. Selects the baked-in vault ID / ingress / path.
CoinmeFieldsFactory that builds VGS field views with the correct Coinme field name pre-set.
VaultFieldNamesThe dot-notation field-name constants VGS deep-merges into nested JSON.
VaultSubmitOptionsPer-submit extraData (non-secure JSON) and headers.
VaultSubmitResultstatus: Int, body: String?, data: JsonElement? on success.
CoinmeVaultExceptionSealed error hierarchy thrown by submit.

The collector is exposed as vault.collector: VGSCollect — the documented escape hatch for advanced VGS usage not covered by the wrapper. You rarely need it.


Creating a vault

val vault = CoinmeVault.create(
    context = context,
    environment = CoinmeVaultEnvironment.SANDBOX,
    config = null,   // optional RuntimeVaultConfig; see "Runtime configuration overrides"
)

create resolves the vault configuration, builds the underlying VGSCollect, and registers a single form-state listener. Create one CoinmeVault per form/screen and dispose() it when the screen goes away.


Field names

Android VGS has no {{ placeholder }} templating. Field names are dot-notation keys that VGS deep-merges with setCustomData into nested submit JSON. They are defined in VaultFieldNames:

ConstantValueNotes
CARD_NUMBERcard.cardNumber 
EXP_MONTHcard.monthchild of the expiry serializer
EXP_YEARcard.yearchild of the expiry serializer
CVCcard.cvv 
CARD_HOLDERcardHolderName 
ADDRESS1billingAddress.addressLine1 
ADDRESS2billingAddress.addressLine2 
CITYbillingAddress.city 
ZIPCODEbillingAddress.zipCode 
EXPIRYcardparent field name for ExpirationDateEditText

Unlike the RN SDK's template allowlist, Android 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("card.month", "card.year") so a single input writes two JSON keys. CoinmeFields.expiry() configures this for you.



Integration Paths

The same CoinmeVault instance supports three ways of constructing fields.

1. CoinmeFields factory (Kotlin / Java)

Recommended for native (View-based and Compose) usage.

VGS field views are final and cannot be subclassed, so the SDK does not ship CoinmeXField subclasses. Instead, CoinmeFields is a factory that builds a stock VGS view with the correct default field name already applied.

import com.coinme.vaultsdk.widget.CoinmeFields

val cardNumber = CoinmeFields.cardNumber(context)   // VGSCardNumberEditText, name "card.cardNumber"
val expiry     = CoinmeFields.expiry(context)       // ExpirationDateEditText, separate-date serializer
val cvc        = CoinmeFields.cvc(context)          // CardVerificationCodeEditText, name "card.cvv"
val holder     = CoinmeFields.cardHolder(context)   // PersonNameEditText, name "cardHolderName"
val custom     = CoinmeFields.text(context, "myField") // VGSEditText, arbitrary field name

container.addView(cardNumber)
container.addView(expiry)
vault.bind(cardNumber, expiry, cvc, holder)

cardNumber, cvc, and cardHolder accept an optional fieldName override; expiry does not (its serializer is fixed); text requires the field name.

2. XML layouts

Declare the VGS view classes directly in your layout and set app:fieldName to the matching VaultFieldNames value.

<com.verygoodsecurity.vgscollect.widget.VGSCardNumberEditText
    android:id="@+id/card_number"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:fieldName="card.cardNumber" />

<com.verygoodsecurity.vgscollect.widget.CardVerificationCodeEditText
    android:id="@+id/cvc"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:fieldName="card.cvv" />

<com.verygoodsecurity.vgscollect.widget.PersonNameEditText
    android:id="@+id/card_holder"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:fieldName="cardHolderName" />
val vault = CoinmeVault.create(this, CoinmeVaultEnvironment.SANDBOX)
vault.bind(
	findViewById(R.id.card_number),    
	findViewById(R.id.cvc),   
	findViewById(R.id.card_holder),
)

Expiry in XML: the separate-date serializer can't be expressed in XML attributes. Either build the expiry field via CoinmeFields.expiry(context) in code and add it to your layout, or set the serializer on the ExpirationDateEditText after findViewById:

findViewById<ExpirationDateEditText>(R.id.expiry).setSerializer(
    VGSExpDateSeparateSerializer(VaultFieldNames.EXP_MONTH, VaultFieldNames.EXP_YEAR)
)

3. Jetpack Compose (AndroidView)

The SDK ships no Compose artifact. VGS fields are Android Views, so Compose support is just AndroidView interop. Copy these two reference helpers into your app (the canonical copy lives in the demo at app/.../screens/VaultFields.kt):

@Composable
fun rememberCoinmeVault(
    environment: CoinmeVaultEnvironment,
    config: RuntimeVaultConfig? = null,
): CoinmeVault {
    val context = LocalContext.current
    val vault = remember(environment, config) {
        CoinmeVault.create(context, environment, config)
    }
    DisposableEffect(vault) { onDispose { vault.dispose() } }
    return vault
}

/** Builds a Coinme widget, binds it to [vault], and renders it via AndroidView. */
@Composable
fun <T : InputFieldView> VaultField(
    vault: CoinmeVault,
    modifier: Modifier = Modifier,
    factory: (Context) -> T,
    configure: (T.() -> Unit)? = null,
) {
    AndroidView(
        modifier = modifier,
        factory = { context ->
            factory(context).apply {
                configure?.invoke(this)
                vault.bind(this)
            }
        },
    )
}


Observing Form State

val formState: StateFlow<VaultFormState>                    // whole-form snapshot
fun fieldState(fieldName: String): Flow<VaultFieldState?>   // one field

data class VaultFormState(
    val fields: Map<String, VaultFieldState> = emptyMap(),
    val isValid: Boolean = false,   // true only when every bound field is valid
)

data class VaultFieldState(
    val fieldName: String,
    val isValid: Boolean,
    val isEmpty: Boolean,
    val isFocused: Boolean,
    val contentLength: Int,
    val cardBrand: String? = null,  // card-number field only
    val last4: String? = null,      // card-number field only
)

Binding seeds each field as invalid (RN parity) — a freshly bound form reports isValid == false until VGS emits its first state change.

No secure values are ever present in these flows — only validity and the non-sensitive metadata shown above.



Submitting

suspend fun submit(options: VaultSubmitOptions = VaultSubmitOptions()): VaultSubmitResult

data class VaultSubmitOptions(
    val extraData: Map<String, Any> = emptyMap(),  // deep-merged into the submit JSON alongside fields
    val headers: Map<String, String> = emptyMap(), // merged with trace headers; caller wins on conflict
)

What submit() does, in order:

  1. Pre-validates from the tracker; if the form is invalid it throws CoinmeVaultException.Validationbefore any network call.
  2. Generates per-request X-B3-TraceId / X-B3-SpanId and merges your headers on top (your headers win on conflict).
  3. Builds a POST to the configured submit path with extraData as custom data, then calls VGS synchronously on Dispatchers.IO.
  4. Maps the response — including the CAAS envelope case where VGS returns HTTP 200 with an errorResponse payload (thrown as CoinmeVaultException.Caas, not returned as success).

Only one submit may be in flight at a time — a concurrent call throws CoinmeVaultException.SubmitInProgress.


Callback variant (Java / non-coroutine)

vault.submit(VaultSubmitOptions()) { result: Result<VaultSubmitResult> ->
    result.onSuccess { /* ... */ }.onFailure { /* ... */ }
}

Payment-method convenience

import com.coinme.vaultsdk.models.PaymentMethodInput
import com.coinme.vaultsdk.models.PaymentProcessAssociation
import com.coinme.vaultsdk.core.submitPaymentMethod

val result = vault.submitPaymentMethod(
    PaymentMethodInput(
        accountId = "abc",
        providerId = "prov-1",
        webSessionId = "sess-1",
        paymentProcessAssociation = PaymentProcessAssociation.BUY,
        // optional billingAddress* fields default to ""/omitted; country defaults to "US"
    )
)

It builds the CAAS extraData shape (accountId, stagingProviderId, webSessionId, paymentProcessAssociation, billingAddress) and delegates to submit.



Error Handling

submit throws CoinmeVaultException, a sealed hierarchy:

try {
    vault.submit(options)
} catch (e: CoinmeVaultException.Validation) {
    e.fields.forEach { (name, reasons) -> showFieldError(name, reasons) }
} catch (e: CoinmeVaultException.Caas) {
    when (e.submitCardError) {
        SubmitCardError.CARD_IN_USE_BY_ANOTHER_USER -> showCardInUse()
        SubmitCardError.MAX_NUMBER_OF_CARDS_REACHED -> showTooManyCards()
        else -> showGenericError()
    }
} catch (e: CoinmeVaultException.Server) {
    showServerError(e.status)
} catch (e: CoinmeVaultException) {
    showGenericError()
}
SubclassMeaningKey fields
ValidationForm invalid; thrown before any network call.fields: Map<String, List<String>> ("required" / "invalid" per field)
NetworkConnectivity / timeout.cause
ServerHTTP 4xx/5xx from the vault.status: Int, body: String?
CaasHTTP 200 with a CAAS error envelope.status: Int, errorResponse: CaasErrorResponse, submitCardError: SubmitCardError?
UnknownAnything else.cause
SubmitInProgressA submit is already in flight.

SubmitCardError (in com.coinme.vaultsdk.enums) maps known CAAS error codes to enum constants and is ported from the Coinme app to ease migration. e.submitCardError resolves the first known error code to a SubmitCardError (or null if the code is unrecognized); the raw code remains available via e.errorResponse.firstKnownError() (a CaasErrorData with errorCode, message).

Exception messages never contain field values (PII). Log them freely.



Runtime Configuration Overrides

The vault ID is always baked in at build time and cannot be overridden. The ingress host and submit path can be overridden at runtime by passing a RuntimeVaultConfig to create:

val 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"),
)
val vault = CoinmeVault.create(context, CoinmeVaultEnvironment.SANDBOX, runtime)

When a RuntimeVaultConfig is supplied, both environments' ingress and path must be non-blank (an IllegalArgumentException is thrown otherwise). When it is null, the baked-in BuildConfig values are used.



Lifecycle

Call vault.dispose() when the form is gone (Activity/Fragment onDestroy, or Compose onDispose via rememberCoinmeVault). It cancels the internal coroutine scope and calls collector.onDestroy() to release VGS resources. After disposal the instance must not be reused — create a new one.



Java Interop

CoinmeVault.create, CoinmeFields.*, and the callback-based submit are annotated @JvmStatic / @JvmOverloads, so they read naturally from Java:

CoinmeVault vault = CoinmeVault.create(context, CoinmeVaultEnvironment.SANDBOX);
VGSCardNumberEditText cardNumber = CoinmeFields.cardNumber(context);
vault.bind(cardNumber);

vault.submit(new VaultSubmitOptions(), result -> {
    if (result.isSuccess()) { /* result.getOrNull() */ }
    return Unit.INSTANCE;
});

For Flow-based state observation from Java, collect via the standard kotlinx-coroutines Java interop or expose a wrapper on the Kotlin side.


Did this page help you?