SoftPoint TTP SDK
The following is a detailed integration guide to implement the SoftPoint SDK in order to do Tap to Phone payments in your own app. If you are interested in Tap to Phone, contact SoftPoint for details.
SoftPoint TTP SDK — Getting started
Take contactless payments on an Android phone, with no card reader and no PCI scope in your app.
This is the integration guide: from an empty Android project to a working tap, then the behaviour
that will matter once you are live. Everything you write is against the SoftPoint TTP SDK's own API
— your app never touches card data and never calls a payments endpoint itself.
SoftPoint TTP SDK version 2.0.0. Requires minSdk 31 (Android 12), compileSdk 36+, Java 17.
Contents
- The whole API in one screen
- Before you write any code
- Step 0 — Run the sample app first
- Step 1 — Add the SDK to your app
- Step 2 — Open a session with
verify - Step 3 — Take a payment
- Step 4 — Persist the result
- Step 5 — Refund or void
- Step 6 — Send a digital receipt
cancel()- A complete minimal integration
- Kotlin
- What the SDK puts on screen
- Rules that will bite you
- Error codes and what to do about them
- Testing without a live card
- Troubleshooting
- Before you ship
- API summary
The whole API in one screen
SoftPointTtp.verify(activity, verifyRequest, verifyCallback); // open a session (also enrolls)
SoftPointTtp.transaction(activity, txnRequest, txnCallback); // SALE, VOID, REFUND
SoftPointTtp.receipt(receiptRequest, receiptCallback); // email / text receipt (no UI)
SoftPointTtp.cancel(); // abandon what's in flightFour static methods make up everything needed. No init, no singleton to fetch, no configuration
object to keep alive, no Application.onCreate hook. verify both configures the SoftPoint TTP SDK
and opens the session, so "used the SDK before configuring it" is not a reachable state.
Everything else in com.softpointdev.ttpsdk is three request builders, two result classes
(TransactionResult and TtpError), three callback interfaces, and three enums — all listed
in the API summary at the end.
Before you write any code
Four things must be true before your first verify can succeed, and none of them can be fixed
from inside your app at runtime. Rows 2 and 3 can be set up for you by SoftPoint, or by your own
provisioning process — but doing it yourself means your own integration against the SoftPoint
developer APIs, which is outside the scope of this SDK.
| What | You need it from | |
|---|---|---|
| 1 | A developer API key | SoftPoint. Issued per environment — a SANDBOX key means nothing on LIVE. |
| 2 | The device registered as a terminal, with its serial number on the terminal record | SoftPoint, or your own provisioning process using SoftPoint Developer APIs. |
| 3 | Tap to Phone enabled on that location, fully configured | SoftPoint, or your own provisioning process using SoftPoint Developer APIs. |
| 4 | Which environment you are pointed at — SANDBOX or LIVE | Your SoftPoint contact. |
You supply exactly three values at runtime: API key, serial number, environment. verify then
validates them and pulls everything else it needs from your account configuration — location,
terminal, employee, merchant credentials, currency — and enrolls the device for Tap to Phone if
it is not enrolled yet and enrollIfNeeded has not been turned off.
Because all of that is discovered rather than supplied, there is nothing else for you to store or
configure. Do not build a settings screen for it, and do not ask a merchant to type any of it in.
Where the serial number comes from
It must match the serial number on the SoftPoint terminal record exactly. Get it from whoever
provisions your devices, or collect it once during your own setup flow and store it.
Do not try to read it with Build.getSerial(). Since Android 10 (API 29) that call requires
READ_PRIVILEGED_PHONE_STATE, which ordinary apps cannot hold: apps targeting API 29+ get a
SecurityException, and older ones get Build.UNKNOWN. The sample app takes it from a text field
because it is a test harness; a production app stores it at provisioning time.
Device requirements
minSdk | 31 (Android 12) — the floor the Tap-to-Phone components require; a tap cannot work below it |
compileSdk | 36 or higher; a lower value fails at dependency resolution |
| Java | 17 |
| Network | HTTPS only. No cleartext-traffic configuration needed |
| Hardware | NFC. An emulator runs everything up to the tap itself, but cannot enroll or tap |
| Device state | NFC switched on and Developer mode switched off — see below |
Developer mode blocks enrollment, not just tapping
If Developer mode is enabled, or NFC is switched off, enrollment fails — so the device never
becomes able to tap at all, no matter how correct your provisioning is. verify reports
onSuccess(false) or an enrollment error rather than anything that names the real cause.
That makes it the first thing to check when enrollment fails on a device whose provisioning you have
already confirmed elsewhere. It also means the device you develop on cannot be the device you take a
real payment on while Developer mode is on: turn it off, then run verify again.
Step 0 — Run the sample app first
Do this before touching your own project. It takes a few minutes and surfaces provisioning problems
before you have written any code.
Install the included sample APK on your device. Its full source is in the packet as well, and it
is the reference integration for this guide — it covers verify → sale → refund → void → receipt
→ cancel, and every snippet here has a working counterpart in it.
- Enter your API key and the device serial.
- Choose the environment.
- Tap Verify.
- Enter a base amount, in cents —
100means $1.00, not1.00— then tap Sale.
verifysucceeds → your provisioning is good. Any failure in your own app is an integration
problem.verifyfails → fix provisioning first. The sample prints the full error, including the HTTP
status and the message returned to it; send that text to your SoftPoint contact so they may assist
in solving the issue.
Step 1 — Add the SDK to your app
SoftPoint delivers the SDK as a single .aar file. Drop it in, add one repository, and declare what
it needs — four short steps. Three are Gradle; 1c is a check on your manifest and theme, which
usually turns out to be nothing to do.
1a. Repository
One extra repository is mandatory, not optional. The Tap-to-Phone components the SDK builds on are
ordinary Maven dependencies that resolve in your build, and they are served from one place:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
// Required: the only source of the Tap-to-Phone components.
maven {
url = uri("https://repo.visa.com/mpos-releases/")
content { includeGroup("io.payworks") }
}
}
}The content { includeGroup(...) } filter is worth keeping: without it Gradle probes that host for
every artifact in your build, which is slower and noisier than it needs to be.
Your build machines and your CI both need network access to it. A build that works on a laptop and
fails in CI is almost always this.
1b. The AAR and its dependencies
Put the .aar in your module's libs/ directory, and match the file name below to the one SoftPoint
sent you. A bare AAR carries no dependency metadata — nothing inside the file tells Gradle what it
needs — so you declare that yourself:
dependencies {
implementation(files("libs/ttp-sdk-2.0.0.aar"))
// Required: the AAR file carries no transitive dependency information.
implementation("io.payworks:paybutton-android:2.115.0")
implementation("io.payworks:api:2.115.0")
implementation("io.payworks:mpos.android.taptophone:2.115.0") {
exclude(group = "com.google.code.gson")
}
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity:1.13.0")
implementation("androidx.annotation:annotation:1.10.0")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
implementation("com.squareup.okhttp3:okhttp:5.4.0")
implementation("com.squareup.okhttp3:logging-interceptor:5.4.0")
implementation("com.google.code.gson:gson:2.14.0")
}Copy it as-is. Two lines in it are load-bearing and look optional:
- Do not drop
io.payworks:mpos.android.taptophone. It is not pulled in by the others, and
without it on the runtime classpathverifycrashes inside the payment stack with
UninitializedPropertyAccessException: lateinit property tapToPhoneProvider has not been initialized.
It is also what sets theminSdk 31floor. - Keep the
exclude(group = "com.google.code.gson"). Without it two Gson versions compete and the
loser is decided by resolution order rather than by you.
If your app already depends on any of the AndroidX or networking entries at a higher version,
leave yours — Gradle picks the highest and these are all compatible upward. Do not force any of them
lower to match this list.
1c. Manifest
You need nothing. INTERNET, NFC, an optional NFC hardware feature, and the SDK's own screens all
arrive from the AAR and merge into your manifest.
Two things on your side must line up:
minSdk31 or higher. If the manifest merger rejects the payment components, yours is too low.
Raise it rather than suppressing the error — suppressing it ships an app that installs on devices
where a tap can never succeed.- An AppCompat or Material application theme, since the SDK's screens are AppCompat activities.
1d. Packaging and R8
If your build fails on a duplicate META-INF entry (two of the bundled libraries ship an
INDEX.LIST):
android {
packaging {
resources {
excludes += setOf("META-INF/INDEX.LIST", "META-INF/DEPENDENCIES")
}
}
}ProGuard / R8: nothing to add. The AAR ships the keep rules it and the payment stack need, and
they are applied to your build automatically. If a release build behaves differently from debug, that
is a defect worth reporting rather than something to paper over with local keep rules.
R8 will print warnings from the payment libraries (missing stack map tables, absent Kotlin companion
objects). Those come from those libraries, not from this SDK, and are expected rather than fatal.
Step 2 — Open a session with verify
verifySoftPointTtp.verify(this, VerifyRequest.builder()
.apiKey(developerApiKey) // required — your SoftPoint developer key
.serialNumber(deviceSerial) // required — must match the SoftPoint terminal record
.environment(TtpEnvironment.SANDBOX) // SANDBOX (default) or LIVE
.enrollIfNeeded(true) // default true
.build(), new VerifyCallback() {
@Override public void onSuccess(boolean deviceEnrolled) {
// Session is open. deviceEnrolled == false means taps will fail until it is enrolled.
}
@Override public void onError(TtpError error) {
// No session. transaction() and receipt() return NOT_VERIFIED until this succeeds.
}
});All three arguments are required; passing null throws IllegalArgumentException immediately.
build() throws IllegalArgumentException for a blank apiKey or serialNumber, so bad
configuration fails during setup rather than mid-payment.
When to call it. Once per app launch is the normal pattern; the session is
process-wide and survives Activity recreation. Call it again at any time to switch key, device, or
environment — the new session replaces the old one completely.
What the user sees. The SDK shows its own full-screen dimmed progress overlay on top of your
Activity and suppresses the back button, so a stray tap cannot leave you half-configured. It is
dismissed before your callback fires, either way. Do not add a spinner of your own — you would be
stacking one on top of the SDK's.
deviceEnrolled == false is not an error. The session is open, but a tap will fail. It happens
when you passed enrollIfNeeded(false), or enrollment was skipped. Call verify again with
enrollIfNeeded(true) to fix it. There is no separate "is this device enrolled?" call: the only two
signals are this flag and a NOT_ENROLLED error from a later charge.
Network timeouts are not yours to set. The SDK uses 30 seconds to connect and 60 to read, which
is tuned for the payment path — a shorter read timeout turns a slow authorization into a transaction
whose outcome you do not know.
Environments
TtpEnvironment has two values:
| Environment | Use it for |
|---|---|
SANDBOX | Integration testing. The default |
LIVE | Production |
The environment selects which SoftPoint deployment you talk to. It does not decide whether the tap
runs against live, test, or mock card processing — see
Testing without a live card.
Step 3 — Take a payment
All amounts are integer cents. $12.00 is 1200. There are no dollar amounts anywhere in this
API.
You do not pass a total. getTotalAmount() is base + tip + donation + surcharge, computed for
you, and the card is charged that sum. There is no setter. If a single figure is all you have, set it
as baseAmount.
SoftPointTtp.transaction(this, TransactionRequest.builder()
.type(TransactionType.SALE)
.baseAmount(1000) // goods
.tipAmount(200) // a tip you already collected, if any
.customIdentifier(myOrderId) // optional but strongly recommended — see below
.ticketNumber(42) // optional POS check number
.note("Table 7") // optional
.build(), new TransactionCallback() {
@Override public void onSuccess(TransactionResult result) { /* persist — see Step 4 */ }
@Override public void onError(TtpError error) { /* see the error table */ }
});The activity argument is required for a SALE. Passing null is legal for VOID and REFUND, so a
SALE without one is reported through the callback as INVALID_ARGUMENT rather than thrown.
build() throws IllegalArgumentException unless the four components sum to more than zero, i.e.
unless getTotalAmount() > 0.
Authorize-without-capture is not available in this release. TransactionType has three constants:SALE, VOID, REFUND.
Set customIdentifier to your own order id. It travels with the payment and comes back on the
result, which is what ties a payment SoftPoint recorded to the order in your own system.
Read the amounts back off the result. They are what was actually charged, not what you asked for
— a tip added on the payment screen appears there and nowhere else.
Step 4 — Persist the result
Do this before you show a success screen, in the same database transaction as your own order
record.
The SDK has no lookup call — the four methods in this guide are the whole API, so whatever you
fail to save here is gone as far as your app is concerned. The record does still exist on SoftPoint's
side, reachable from the SoftPoint back office or from your own backend via the SoftPoint Developer
APIs, but that is a recovery path and a separate integration, not something the SDK does for you.
The one you cannot lose:
| Getter | What it is |
|---|---|
getPaymentId() | SoftPoint's id for this payment, and the only argument a later void, refund, or receipt actually needs. Lose it and you cannot reverse the payment from your app at all. |
Worth saving, in rough order of how often you will want it:
| Getter | What it is |
|---|---|
getCustomIdentifier() | Your order id, echoed back. |
getTotalAmount() | What was actually charged, tip included. |
getBaseAmount(), getTipAmount(), getDonationAmount(), getSurchargeAmount() | The breakdown of that total. |
isSuccess(), getResultCode(), getResultMessage() | The outcome and the processor's own words for it. Worth storing verbatim — it is what you quote when someone disputes a charge. |
getCardType(), getLast4() | What a customer recognises on a receipt or in an order history. |
getCardHolder(), getCardBin(), getAuthCode(), getEntryType() | Card and authorization detail for your own receipts and reporting. |
getTransactionNo() | The card processor's identifier — what you reconcile a processor statement against. |
getReferenceId() | The tap's own identifier — what you reconcile SoftPoint's record against. |
getCurrencyIso(), getExpMonth(), getExpYear() | Reporting. |
Not worth persisting. getSoftPointTransactionId() is SoftPoint's own bookkeeping row behind the
payment — nothing takes it as an argument, so store it only if you want it in your logs.
getSaleType(), getEmvTagsJson(), getResponseJson(), and getCcToken() are diagnostic or
raw-passthrough data a normal integration has no use for — read them if you are debugging, not as
part of your persistence. (getSaleType() is the constant "CP", card-present, on every result this
SDK produces.)
Which id is which
Several getters look like ids, and only getPaymentId() is a handle you pass back in:
| Getter | Whose id | What it identifies |
|---|---|---|
getPaymentId() | SoftPoint | The payment. The argument to a later VOID, REFUND, or receipt. |
getSoftPointTransactionId() | SoftPoint | The transaction record behind that payment — bookkeeping, never an argument. |
getCustomIdentifier() | Yours | The customIdentifier you set on the request, echoed back. |
getReferenceId() | Tap-to-Phone stack | The tap's own identifier. |
getTransactionNo(), getPaymentIdExternal() | Card processor | The processor's identifier for the authorization — what a processor statement is keyed on. Both are populated from the same value, so persist one, not two. Prefer getTransactionNo(): when the tap returns no processor identifier, SoftPoint's own record can still fill that one, while getPaymentIdExternal() stays null. |
Every object-typed getter can return null, and integer ones can be 0. How much of a result is
populated depends on how far the payment got: a decline carries far less than an approval, and a
result assembled after a partial failure may have identifiers but no card data. Null-check rather
than assuming a complete record.
Step 5 — Refund or void
No tap, no card present, no UI. The activity argument is unused for these types and may be null.
SoftPointTtp.transaction(null, TransactionRequest.builder()
.type(TransactionType.REFUND) // or VOID
.paymentId(savedPaymentId) // required, > 0 — the id you saved in Step 4
.full(true) // default true; false for a partial refund
.baseAmount(savedBase) // the amount to reverse, plus any tip below
.tipAmount(savedTip)
.build(), callback);That is the whole call. A refund needs the payment id, the amount, and whether it is full — nothing
else about the original sale, because SoftPoint already has the record you are reversing.
build() throws IllegalArgumentException unless paymentId > 0. The amount reversed is the sum of
the components you set — base, tip, donation, surcharge. If you only have one figure, put it in
baseAmount.
Set full(false) for a partial refund. It defaults to true and is sent to SoftPoint as the
full flag on the refund record, alongside the amount. Send the two consistently — a partial amount
still marked full is a contradiction SoftPoint is being asked to resolve.
TransactionRequest has further setters this call will accept — cardType, cardHolder, cardBin,
last4, authCode, transactionNo. They are passed through to SoftPoint and echoed back on the
result, but they are not required: a refund issued from a saved payment id alone works without any
of them. If you do send them, send values belonging to the payment you are actually reversing — a
mismatch is stored without complaint.
ALREADY_REFUNDED arrives on onSuccess
ALREADY_REFUNDED arrives on onSuccess@Override public void onSuccess(TransactionResult result) {
if ("ALREADY_REFUNDED".equals(result.getResultCode())) {
// This payment was already refunded. Your intent is satisfied. DO NOT RETRY.
return;
}
// resultCode "APPROVED" — the refund went through just now.
}Refunding a payment that is already refunded is reported as success, because the caller's intent
— that this payment end up refunded — is satisfied either way. Integrators who treat it as a failure
retry, and double-refund. It is the single most common integration bug on this API.
A repeat VOID reports the same code. There is no ALREADY_VOIDED — check for
"ALREADY_REFUNDED" whichever of the two you sent.
Step 6 — Send a digital receipt
This one has no UI. It takes no Activity, opens no screen, and does a single REST call on a
background thread. Requires a successful prior verify.
That means you build the form. Ask for a first name and either an email address or a phone number,
however suits your app — or don't ask at all and pull the details from a loyalty record. Then:
SoftPointTtp.receipt(ReceiptRequest.builder()
.paymentId(savedPaymentId) // required, > 0
.firstName(firstName) // required by SoftPoint
.lastName(lastName) // optional, may be null
.email(emailOrNull) // exactly one of email...
.phone(phoneOrNull) // ...or phone. The other may be null.
.phoneCountryCode(dialCode) // text receipts only; default 1
.build(), new ReceiptCallback() {
@Override public void onSuccess() { } // the receipt was accepted
@Override public void onError(TtpError error) { }
});Four things worth knowing:
phoneCountryCodeapplies to text receipts only. Send an email address and it is ignored
entirely — the dial code is dropped from the request rather than sent as a meaningless value, so
you can leave it set on a shared builder without it leaking onto an email receipt.build()does the validating, and it throwsIllegalArgumentException— catch it around the
builder the same way you do forTransactionRequest. Missing first name, no contact channel, both
channels, and a phone with no digits are all rejected there rather than arriving at SoftPoint as a
bad request.- The phone is normalised for you.
"+1 (555) 123-4567"with dial code1becomes5551234567;
SoftPoint wants national digits with the dial code sent separately. Don't pre-strip it yourself. - A failed send is a plain
onError. There is no screen left open for the guest to correct their
address, so retrying is your call: rebuild the request with a corrected address and call again.
ReceiptActivity.java in the sample source is a complete working form — channel chooser, dial-code
picker, validation — that you can copy as a starting point.
cancel()
cancel()SoftPointTtp.cancel();Asks the payment UI to abort, then fails any pending transaction — or pending enrollment — callback
with CANCELLED. Never throws. Safe to call when nothing is in flight, and safe before the first
verify.
What it does not do:
- It does not interrupt the network half of
verify. Until enrollment UI is on screen there is
nothing to abort, socancel()is a no-op andverifyruns to completion. - It does not unwind an authorization that already happened. If the abort arrives too late, the card
was charged. Reconcile rather than assuming the money did not move.
You will not get a duplicate callback: once cancel() has taken the pending result, the real payment
outcome has nothing left to deliver.
Cancelling an enrollment: two paths, two codes. Easy to confuse, because both end the same
screen.
| Who cancelled | VerifyCallback gets |
|---|---|
Your app, by calling cancel() while enrollment is on screen | CANCELLED |
| The user, by backing out of the enrollment screen itself | ENROLLMENT_FAILED |
Only one of them ever fires. Whichever happens first takes the pending callback, and the other finds
nothing left to report — so a cancelled enrollment produces exactly one error, never both.
A complete minimal integration
Everything above, with no UI framework beyond three buttons.
public class PaymentActivity extends AppCompatActivity {
// A real app persists these (Step 4). Fields are the shortcut for a short example.
@Nullable private TransactionResult lastSale;
@Override protected void onCreate(@Nullable Bundle state) {
super.onCreate(state);
setContentView(R.layout.activity_payment);
findViewById(R.id.btnStart).setOnClickListener(v -> openSession());
findViewById(R.id.btnCharge).setOnClickListener(v -> charge(1200));
findViewById(R.id.btnRefund).setOnClickListener(v -> refundLastSale());
}
/** Once per launch or sign-in. The session is process-wide and survives recreation. */
private void openSession() {
VerifyRequest request;
try {
request = VerifyRequest.builder()
.apiKey(apiKeyFromYourBackend()) // never hardcoded in the APK
.serialNumber(deviceSerialFromProvisioning())
.environment(TtpEnvironment.SANDBOX)
.enrollIfNeeded(true)
.build();
} catch (IllegalArgumentException e) {
toast("Bad configuration: " + e.getMessage());
return;
}
SoftPointTtp.verify(this, request, new VerifyCallback() {
@Override public void onSuccess(boolean deviceEnrolled) {
if (!deviceEnrolled) {
toast("This device is not enrolled for Tap to Phone — taps will fail.");
}
setChargingEnabled(deviceEnrolled);
}
@Override public void onError(TtpError error) {
setChargingEnabled(false);
toast("Could not open a session: " + error.getCode() + " " + error.getMessage());
}
});
}
private void charge(int baseCents) {
setChargingEnabled(false); // one charge in flight at a time — enforce it here
TransactionRequest request = TransactionRequest.builder()
.type(TransactionType.SALE)
.baseAmount(baseCents)
.customIdentifier(myOrderId()) // your key for reconciliation
.build();
SoftPointTtp.transaction(this, request, new TransactionCallback() {
@Override public void onSuccess(TransactionResult result) {
setChargingEnabled(true);
// Persist BEFORE showing success. Amounts come from the tap, not from the request.
orders.record(myOrderId(),
result.getPaymentId(),
result.getTransactionNo(),
result.getReferenceId(),
result.getCardType(), result.getCardBin(), result.getLast4(),
result.getCardHolder(), result.getAuthCode(),
result.getBaseAmount(), result.getTipAmount(),
result.getSurchargeAmount(), result.getTotalAmount());
lastSale = result;
// Your screen, not the SDK's. Collect a first name and an email or phone, then
// call sendReceipt(...) below with them.
showYourReceiptForm(result.getPaymentId());
}
@Override public void onError(TtpError error) {
setChargingEnabled(true);
switch (error.getCode()) {
case DECLINED: toast("Card declined: " + error.getMessage()); break;
case CANCELLED: toast("Cancelled."); break;
case NOT_ENROLLED: toast("Enroll this device, then try again."); break;
case SOFTPOINT_API: // The card may have been charged — see the next section.
case NETWORK:
orders.flagForReconciliation(myOrderId());
toast("This payment needs review: " + error.getMessage());
break;
default: toast(error.getCode() + ": " + error.getMessage());
}
}
});
}
private void refundLastSale() {
TransactionResult sale = lastSale;
if (sale == null || sale.getPaymentId() <= 0) {
toast("Nothing to refund.");
return;
}
TransactionRequest request = TransactionRequest.builder()
.type(TransactionType.REFUND)
.paymentId(sale.getPaymentId())
.full(true)
.baseAmount(sale.getBaseAmount())
.tipAmount(sale.getTipAmount())
.transactionNo(sale.getTransactionNo())
.cardType(sale.getCardType())
.cardHolder(sale.getCardHolder())
.cardBin(sale.getCardBin())
.last4(sale.getLast4())
.authCode(sale.getAuthCode())
.build();
SoftPointTtp.transaction(null, request, new TransactionCallback() {
@Override public void onSuccess(TransactionResult result) {
boolean alreadyDone = "ALREADY_REFUNDED".equals(result.getResultCode());
orders.markRefunded(sale.getPaymentId());
lastSale = null; // never offer it as a refund origin again
toast(alreadyDone ? "Already refunded." : "Refunded.");
}
@Override public void onError(TtpError error) {
toast("Refund failed: " + error.getCode() + " " + error.getMessage());
}
});
}
/**
* Called by showYourReceiptForm above, with what it collected — the SDK has no screen of its
* own. Pass null for the channel the guest did not choose.
*/
private void sendReceipt(int paymentId, String firstName, String email, String phone) {
ReceiptRequest request;
try {
request = ReceiptRequest.builder()
.paymentId(paymentId)
.firstName(firstName)
.email(email)
.phone(phone)
.phoneCountryCode(1)
.build();
} catch (IllegalArgumentException e) {
toast("Receipt: " + e.getMessage());
return;
}
SoftPointTtp.receipt(request, new ReceiptCallback() {
@Override public void onSuccess() { toast("Receipt sent."); }
@Override public void onError(TtpError error) { toast("Receipt: " + error.getMessage()); }
});
}
}Kotlin
The API is designed so its Java projection reads naturally, and it works just as well from Kotlin:
SoftPointTtp.verify(
this,
VerifyRequest.builder()
.apiKey(apiKey)
.serialNumber(serial)
.environment(TtpEnvironment.SANDBOX)
.build(),
object : VerifyCallback {
override fun onSuccess(deviceEnrolled: Boolean) { /* ... */ }
override fun onError(error: TtpError) { /* ... */ }
},
)
SoftPointTtp.transaction(
this,
TransactionRequest.builder()
.type(TransactionType.SALE)
.baseAmount(1200)
.build(),
object : TransactionCallback {
override fun onSuccess(result: TransactionResult) {
if (result.isSuccess) save(result.paymentId, result.last4, result.totalAmount)
}
override fun onError(error: TtpError) = when (error.code) {
TtpError.Code.DECLINED -> showDeclined(error.message)
else -> showError(error)
}
},
)From Kotlin the results read as properties (result.paymentId, result.isSuccess, error.code) and
the builders as functions. Builder setters accept nullable String? on purpose, so a null you happen
to be holding produces the documented IllegalArgumentException from build(), naming the field
that is actually missing, rather than an opaque NullPointerException at the setter.
What the SDK puts on screen
Three screens are launched for you, all on the payment path. Knowing they exist answers most "why did
my Activity pause?" questions.
| When | What | Notes |
|---|---|---|
During verify | Dimmed full-screen progress overlay | Back suppressed. Dismissed before your callback. Do not add your own. |
| During enrollment | Device-enrollment UI | Only when the device is not enrolled and enrollIfNeeded is true. |
| During a SALE | The payment screen | Card only, no signature capture, no summary screen — control returns to you immediately. Back cancels with CANCELLED. |
receipt shows nothing — that form is yours to build.
Your Activity is paused, not destroyed, for all of these.
Rules that will bite you
onError does not always mean no money moved. On a SALE the card is charged before the payment
is recorded. If that record fails you get an error — correctly, because the payment is not on file —
but the customer was charged. Treat SOFTPOINT_API and NETWORK on a SALE as "needs
reconciliation", not "declined". The same applies to a late cancel().
One verify and one charge in flight per process. Starting a second SALE while one is pending
replaces the first, whose callback then never fires. Disable your charge button for the duration.
Callbacks are lost if the process dies mid-transaction. If Android kills your app while the
payment screen is up, the money may still move and you will never hear about it. The SDK cannot
recover it; look the payment up by your customIdentifier as described in
Step 4. Silence is a case to handle, not one that cannot happen.
Your callback holds your Activity. Anonymous callbacks capture this. If the device rotates
during a tap, the pending callback still points at the destroyed Activity — leaking it, and
delivering the result somewhere that can no longer show it. Either lock orientation on your payment
screen, or hold the callback in a ViewModel or application-scoped object and check isFinishing
before touching views.
Threading. Every callback is delivered on the main thread, with one exception: if verify has
never been called, transaction and receipt report NOT_VERIFIED synchronously on the calling
thread. All network work happens on a background thread the SDK owns, so no call blocks you and two
transactions never run at once.
verify again means everything is new. New session, possibly a new location and terminal. Payment
ids are only unique within a location, so discard anything you remembered from the previous session.
Builders validate eagerly. build() throws IllegalArgumentException with a specific message
("apiKey is required", "paymentId is required for VOID/REFUND", "amount is required for SALE").
Catch it where you build the request, not where you take the payment.
Error codes and what to do about them
Every failure arrives as a TtpError with getCode(), getMessage(), an optional getHttpStatus(),
and an optional getCause(). For SOFTPOINT_API the message carries SoftPoint's own wording — log
it verbatim; it is usually the whole diagnosis.
| Code | Means | What your app should do |
|---|---|---|
NOT_VERIFIED | No session. verify was not called, or failed. | Call verify. Do not retry the payment blindly. |
INVALID_ARGUMENT | Missing or invalid argument — usually no activity on a SALE. | Fix the call. This is a programming error, not a runtime condition. |
ENROLLMENT_FAILED | Enrollment ran and failed, or the user backed out of it. Your own cancel() gives CANCELLED instead — see cancel(). | Surface it and offer a retry. Taps will not work until it succeeds. |
NOT_ENROLLED | The device is not enrolled, so the payment could not complete. No card was charged. | Call verify again with enrollIfNeeded(true). |
SOFTPOS_FAILED | The payment UI or its result could not be processed. | Retry once. If it repeats, contact SoftPoint — include the message. |
DECLINED | The card was declined. | Show getMessage(), offer another card. No money moved. |
NETWORK | SoftPoint could not be reached. | Retry. If this happened after a tap, reconcile. |
SOFTPOINT_API | SoftPoint returned an error; getHttpStatus() is set. | Before a tap: configuration. After a tap: reconcile — the card may have been charged. |
CANCELLED | The user backed out, or you called cancel(). | Usually nothing. If the tap had already started, reconcile. |
UNSUPPORTED | Retained for source compatibility; never produced. | Nothing. |
UNKNOWN | Anything else. getCause() may carry the exception. | Log the cause and report it. |
Messages worth recognising during setup — all of them arrive as SOFTPOINT_API:
| Message contains | Cause |
|---|---|
app-pre-token returned no temp_access_token | Wrong environment for this deployment, or the app is not authorised on it. |
auto-login response missing access_token, location_id, or terminal id | The serial number is not registered as a terminal in this environment. |
settings response has no tap_to_phone processor | The location is not set up for Tap to Phone. |
tap_to_phone processor missing mid/merchant_id or api_key | Tap to Phone is enabled but not fully configured. |
Each of those is fixed by SoftPoint, not by your code. Send the message text to your contact.
Testing without a live card
Whether a tap runs against live, test, or mock card processing is part of the location's Tap-to-Phone
configuration. You cannot set it from the app. If you need mock processing, ask SoftPoint for a
mock-configured location.
What you can exercise without NFC hardware:
- The whole session and bookkeeping side:
verifyand every failure it can produce, plus refund,
void, andreceipt— none of those involve a tap. - Request-builder validation and your own error handling.
verify also prepares the payment stack and may attempt enrollment, so on a device without NFC
expect onSuccess(false) or an enrollment error rather than a clean pass — the session is still open
at that point, which is what you are testing.
What needs real hardware: enrollment, the tap itself, and any SDK version upgrade. A green build
proves nothing about a tap.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Could not find io.payworks:paybutton-android | The Visa repository is missing or unreachable from your build or CI | Add https://repo.visa.com/mpos-releases/ (Step 1a) |
Could not resolve all files ... libs/ttp-sdk-<version>.aar | The .aar is not in your module's libs/, or the file name does not match what you declared | Check both against what SoftPoint sent you (Step 1b) |
uses-sdk:minSdkVersion N cannot be smaller than version 31 declared in library io.mpos.taptophone (the artifact you declared as io.payworks:mpos.android.taptophone) | Your minSdk is below 31 | Raise it to 31 (Step 1c) |
2 files found with path 'META-INF/INDEX.LIST' | Two bundled libraries ship the same metadata file | Add the packaging excludes (Step 1d) |
UninitializedPropertyAccessException: ... tapToPhoneProvider ... | io.payworks:mpos.android.taptophone is not on the runtime classpath | Remove whatever excludes it, or declare it (Step 1b) |
verify fails with SOFTPOINT_API | Provisioning — see the message table above | Send the message text to SoftPoint |
verify succeeds but deviceEnrolled is false, or every charge returns NOT_ENROLLED | The device is not enrolled | Call verify again with enrollIfNeeded(true). If it still fails, check NFC is on and Developer mode is off |
| A charge callback never fires | A second charge replaced the first, or the process was killed | Disable the button while one is in flight; reconcile by customIdentifier |
| The second refund or void of a payment looks like a failure | ALREADY_REFUNDED is being treated as an error | It arrives on onSuccess — check getResultCode(). VOID reports ALREADY_REFUNDED too (Step 5) |
| Release build behaves differently from debug | R8 stripped something | Report it — the AAR ships its own keep rules and should not need help |
Before you ship
- The API key comes from your backend at runtime — not from the APK, not from a UI field
-
TtpEnvironment.LIVE, with a key and terminal provisioned for production - Every field in Step 4 is persisted before the success screen
-
customIdentifieris set to your own order id on every SALE -
ALREADY_REFUNDEDis handled as a success, on both REFUND and VOID -
SOFTPOINT_APIandNETWORKafter a tap route into a reconciliation path, not a "declined"
message - Charge and refund controls are disabled while a request is in flight
- Orientation is locked on the payment screen, or callbacks do not hold Activity references
- You have run a release (R8) build on a real device, not just debug
- You have taken a real tap on real hardware
API summary
Everything in com.softpointdev.ttpsdk. Anything not listed here is internal and may change without
notice.
Entry point — SoftPointTtp, four static methods:
verify(Activity, VerifyRequest, VerifyCallback),
transaction(Activity, TransactionRequest, TransactionCallback),
receipt(ReceiptRequest, ReceiptCallback) — no Activity, no UI — and cancel().
Requests, all built through builder():
| Type | Required | Optional |
|---|---|---|
VerifyRequest | apiKey, serialNumber | environment (default SANDBOX), enrollIfNeeded (true) |
TransactionRequest | type; plus an amount for SALE, or paymentId for VOID/REFUND | baseAmount, tipAmount, donationAmount, surchargeAmount, customIdentifier, ticketNumber, note, full, transactionNo, paymentIdExternal, cardType, cardHolder, cardBin, last4, authCode.Only on SoftPoint's instruction: ticketId, tipType, webhookId |
ReceiptRequest | paymentId, firstName, and exactly one of email / phone | lastName, phoneCountryCode (1, text receipts only) |
Result — TransactionResult, read-only; see Step 4.
Callbacks — VerifyCallback (onSuccess(boolean), onError), TransactionCallback
(onSuccess(TransactionResult), onError), ReceiptCallback (onSuccess, onError).
All delivered on the main thread, with one exception noted under
Rules that will bite you.
Enums — TransactionType (SALE, VOID, REFUND), TtpEnvironment (SANDBOX, LIVE),
TtpError.Code (see the error table).
Updated about 4 hours ago

