POS Terminal Android Quickstart
Use the Forage Terminal SDK to accept EBT SNAP and other payment methods via a POS system.
In this guide, you’ll learn how to:
- Set up a Forage Android app
- Add and style a Forage Element
- Configure a Forage Terminal instance
- Perform POS-specific payment operations
Prerequisites
- Sign up for a Forage account (contact us if you don’t yet have one)
- Install Android API Level 21 or higher
- Install the following dependencies to copy/pasted code snippets and run the sample-app:
Resources
Step 1: Set up a Forage Android app
-
Add an endpoint to your server that creates a session token.
A
POST
to the Forage/session_token/
endpoint creates a session token.
The following examples assume that environment variables are stored in a
.env
Edit the relevant lines of code if that doesn’t match your token management preferences or dev environment. Just be careful to never expose sensitive data like authentication tokens in client-side requests.
// app.js
const axios = require('axios')
const express = require('express')
const bodyParser = require('body-parser')
require('dotenv').config();
const app = express()
const port = 5000
// Middleware to parse the request body
app.use(bodyParser.urlencoded({ extended: true }))
app.post('/api/session_token', async (req, res) => {
try {
const apiUrl = 'https://api.sandbox.joinforage.app/api/session_token/'
// Replace the below with your authentication token
const authenticationToken = process.env.AUTHENTICATION_TOKEN
// Replace the below with your Forage merchant ID
const merchantId = process.env.MERCHANT_ID
const headers = {
Authorization: `Bearer ${authenticationToken}`,
'Merchant-Account': merchantId,
'Content-Type': 'application/json'
}
const response = await axios.post(apiUrl, null, { headers })
res.json(response.data)
} catch (error) {
res
.status(error.response ? error.response.status : 500)
.json({ error: 'Something went wrong' })
}
})
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`)
})
# app.rb
require 'sinatra'
require 'net/http'
require 'json'
require 'dotenv/load'
class ApiController < Sinatra::Base
post '/session_token' do
begin
api_url = 'https://api.sandbox.joinforage.app/api/session_token/'
# Replace the below with your authentication token
authentication_token = ENV['AUTHENTICATION_TOKEN']
# Replace the below with your Forage merchant ID
merchant_id = ENV['MERCHANT_ID']
uri = URI(api_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
headers = {
'Authorization' => "Bearer #{authentication_token}",
'Merchant-Account' => merchant_id,
'Content-Type' => 'application/json',
}
response = http.post(uri.path, nil, headers)
if response.is_a?(Net::HTTPSuccess)
json_response = JSON.parse(response.body)
content_type :json
json_response.to_json
else
status response.code
{ error: 'Something went wrong' }.to_json
end
rescue StandardError => e
status e.respond_to?(:status) ? e.status : 500
{ error: 'Something went wrong' }.to_json
end
end
end
- Install the Android SDK.
Add the forage-android
dependency to your build.gradle
module. Replace x.x.x
with the Forage version of your choice:
// build.gradle
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
// ...
}
dependencies {
// ...
// Forage Android SDK
implementation 'com.joinforage:forage-android:x.x.x'
// ...
}
Add maven("https://jitpack.io")
inside the repositories block in settings.gradle.kts
:
// settings.gradle.kts
pluginManagement {
// ...
}
dependencyResolutionManagement {
// …
repositories {
// ...
maven("https://jitpack.io")
}
}
// ...
Step 2: Add and style a Forage Element
- Add a Forage Element.
A Forage Element is a secure, client-side entity that accepts and submits customer input for an EBT transaction.
The Android SDK includes an Element for collecting an EBT Card Number, ForagePANEditText
, and an Element to collect an EBT Card PIN, ForagePINEditText
.
You add Elements to your layout file. The following example adds ForagePINEditText
:
<!-- forage_pin_component.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.joinforage.forage.android.ui.ForagePINEditText
android:id="@+id/foragePinEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:elementWidth="match_parent"
app:elementHeight="wrap_content"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
- Style a Forage Element.
Whether you’re styling a
ForagePINEditText
or aForagePANEditText
Element, the steps are the same. The below examples style a PIN Element.
For a comprehensive list of available customizable styling properties, check out the Android styling reference documentation.
First, add Forage specific theme attributes to your attrs.xml
file. The below example adds a tokenizeForagePINEditTextStyle
:
<!-- attrs.xml -->
<resources>
...
<!-- Theme attribute for the ForagePINEditText on the tokenize fragment. -->
<attr name="foragePinEditTextStyle" format="reference" />
...
</resources>
Then apply the attribute as a style
tag on the Element component file, as in this example snippet:
<!-- forage_pin_component.xml -->
<!-- abridged snippet, style tag only -->
<com.joinforage.forage.android.ui.ForagePINEditText
style="?attr/foragePinEditTextStyle"
/>
Next, add an <item>
tag for the style in themes.xml
, as in the following example:
<!-- themes.xml -->
<resources>
<!-- Base application theme. -->
<style name="Theme.Forageandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
...
<!-- The ForagePINEditText style -->
<item name="foragePinEditTextStyle">@style/ForagePinEditTextStyle</item>
...
</style>
</resources>
Finally, define the style’s properties in styles.xml
. The below snippet specifies boxStrokeWidth
and boxStrokeColor
for the Element:
<!-- styles.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
...
<!-- Style for the ForagePINEditText -->
<style name="ForagePinEditTextStyle">
<item name="boxStrokeWidth">1dp</item>
<item name="boxStrokeColor">@color/mtrl_textinput_default_box_stroke_color</item>
</style>
...
</resources>
Step 3: Configure a Forage Terminal instance
-
Set up a request from your front-end to your server to generate a session token.
The below example sends a request from a
ViewModel.kt
file for a specific EBT transaction, in this case tokenizing an EBT Card number:// TokenizeViewModel.kt data class TokenResponse( val token: String, // ...other fields ) interface YourServerApi { @POST("api/session_token") suspend fun getSessionToken(): TokenResponse } class TokenizeViewModel : ViewModel() { private val apiService = Retrofit.Builder() .baseUrl("your server's base url") .addConverterFactory(GsonConverterFactory.create()) .build() .create(YourServerApi::class.java) private val merchantId = BuildConfig.MERCHANT_ID suspend fun fetchSessionToken(): String { return withContext(Dispatchers.IO) { try { val response = apiService.getSessionToken() response.token } catch (e: Exception) { // Handle the error // (e.g., log or throw a custom exception) throw e } } } }
During testing and development, you can opt to hardcode an authentication token and the merchant’s account number instead. Just remember to scrub placeholder values before going live in production. To keep your app secure, never expose sensitive tokens on the front-end.
-
Pass the returned session token, along with the merchant account number, in a call to
setPosForageConfig()
.
setPosForageConfig()
sets foundational settings for a Forage Element, passed as the PosForageConfig
parameter.
You must call setPosForageConfig()
on an Element before invoking any other methods, as in the below example for a ForagePINEditText
Element:
val foragePinEditText = root?.findViewById<ForagePINEditText>(R.id.foragePinEditText)
foragePinEditText.setPosForageConfig(
PosForageConfig(
sessionToken = 'sandbox_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...',
merchantId = 'mid/123ab45c67'
)
)
Example: Call setPosForageConfig()
in a Fragment
The following snippet calls setPosForageConfig()
after a Fragment for a specific payment operation, in this case a refund, is created:
// PosRefundFragment.kt
@AndroidEntryPoint
class PosRefundFragment : Fragment() {
private val viewModel: PosRefundViewModel by viewModels()
private var _binding: PosRefundFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// establish bindings to ForagePANEditText
_binding = PosRefundFragmentBinding.inflate(inflater, container, false)
val foragePinEditText: ForagePINEditText = binding.posRefundForagePinEditText
// immediately call setPosForageConfig() on the binding
foragePinEditText.setPosForageConfig(viewModel.forageConfig)
// then freely call other methods on ForagePINEditText binding
foragePinEditText.requestFocus()
}
}
- Call
ForageTerminalSDK.init()
to initialize the Forage Terminal SDK.
ForageTerminalSDK.init()
creates a Forage Terminal instance. Pass the Context
, posTerminalId
that uniquely identifies the POS Terminal, the merchantId
, and the sessionToken
.
try {
val forageTerminalSdk =
ForageTerminalSDK.init(
context = getApplicationContext(),
posTerminalId = "<id-that-uniquely-identifies-the-pos-terminal>",
posForageConfig = PosForageConfig(
merchantId = "mid/123ab45c67",
sessionToken = "sandbox_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
)
)
// Use the forageTerminalSdk to call other methods
// (e.g. tokenizeCard, checkBalance, deferPaymentCapture, etc.)
} catch (e: Exception) {
// handle initialization error
}
In very few cases,
init
can take up to 10 seconds to run.Although it rarely takes this long, account for the potential delay in your app’s UX design.
Step 4: Perform POS-specific payment operations
For a Forage Terminal integration, call all methods to perform payment operations on a ForageTerminalSDK
instance.
Aside from the instance type, methods for the following operations behave the same way for both ForageTerminalSDK
and ForageSDK
:
- Check a card's balance
- Collect a customer's card PIN for a payment and defer the capture of the payment to the server
- Capture a payment immediately
Read on for details about POS-specific operations, including:
- Tokenize a card via a
ForagePANEditText
Element - Tokenize a card via a magnetic swipe
- Refund a POS payment
Tokenize a card via a ForagePANEditText
Element
ForagePANEditText
ElementTo tokenize a card via a a ForagePANEditText
Element, call tokenizeCard()
on a ForageTerminalSDK
instance, passing a reference to the Element in the method call.
Example tokenizeCard()
request
tokenizeCard()
request// TokenizeViewModel.kt
@HiltViewModel
class TokenizeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val moshi: Moshi
) : ViewModel() {
private val args = TokenizeFragmentArgs.fromSavedStateHandle(savedStateHandle)
// internal so that TokenizeFragment can access these values
val merchantId = args.merchantId
val sessionToken = args.sessionToken
fun tokenizeCard(foragePanEditText: ForagePANEditText) = viewModelScope.launch {
_isLoading.value = true
val response = forageTerminalSdk.tokenizeCard(
foragePanEditText = foragePanEditText,
reusable = true
)
when (response) {
is ForageApiResponse.Success -> {
val adapter: JsonAdapter<PaymentMethod> = moshi.adapter(PaymentMethod::class.java)
val paymentMethod = adapter.fromJson(response.data)
// (optional) do something with the ref
saveToPaymentMethodRefToMyAPI(paymentMethod.ref)
}
is ForageApiResponse.Failure -> {
_error.value = response.message
}
}
_isLoading.value = false
}
}
Tokenize a card via a magnetic swipe
To tokenize a card from a magnetic card swipe on a POS Terminal, call tokenizeCard()
on a ForageTerminalSDK
instance, passing PosTokenizeCardParams
.
PosTokenizeCardParams
PosTokenizeCardParams
data class PosTokenizeCardParams(
val posForageConfig: PosForageConfig,
val track2Data: String,
val reusable: Boolean = true
)
posForageConfig
(required): The configuration details required to authenticate with the Forage API.merchantId
: A unique Merchant ID that Forage provides during onboarding onboarding preceded by "mid/". For example,mid/123ab45c67
. The Merchant ID can be found in the Forage Sandbox or Production Dashboard.sessionToken
: A short-lived token that authenticates front-end requests to Forage. To create one, send a server-side request from your backend to the/session_token/
endpoint.
track2data
(required): The information encoded on Track 2 of the EBT Card’s magnetic stripe, excluding the start and stop sentinels and any LRC characters.reusable
: An optional boolean value indicating whether the same card can be used to make multiple payments, set totrue
by default.
Example tokenizeCard(PosTokenizeCardParams)
request
tokenizeCard(PosTokenizeCardParams)
request// TokenizePosViewModel.kt
@HiltViewModel
class TokenizePosViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val moshi: Moshi
) : ViewModel() {
private val args = TokenizePosFragmentArgs.fromSavedStateHandle(savedStateHandle)
// internal so that TokenizePosFragment can access these values
val merchantId = args.merchantId
val sessionToken = args.sessionToken
val track2Data = args.track2Data
fun tokenizePosCard() = viewModelScope.launch {
_isLoading.value = true
val response = forageTerminalSdk.tokenizeCard(
PosTokenizeCardParams(
posForageConfig = PosForageConfig(
merchantId = merchantId,
sessionToken = sessionToken
),
track2Data = track2Data,
reusable = true
)
)
when (response) {
is ForageApiResponse.Success -> {
val adapter: JsonAdapter<PaymentMethod> = moshi.adapter(PaymentMethod::class.java)
val paymentMethod = adapter.fromJson(response.data)
// (optional) do something with the ref
saveToPaymentMethodRefToMyAPI(paymentMethod.ref)
}
is ForageApiResponse.Failure -> {
_error.value = response.message
}
}
_isLoading.value = false
}
}
Example PosPaymentMethod class
Note that the response object takes the same shape no matter what tokenization method you choose.
Refund a POS payment
Whether you process a refund immediately or defer refund completion to the server, if a refund attempt fails then prompt the customer to repeat the request.
Process a refund immediately
To refund an EBT POS payment immediately, pass PosRefundPaymentParams
to the refundPayment()
method.
data class PosRefundPaymentParams(
val foragePinEditText: ForagePINEditText,
val paymentRef: String,
val amount: Float,
val reason: String,
val metadata: Map<String, String>? = null
)
suspend fun refundPayment(
params: RefundPaymentParams
): ForageApiResponse<String>
PosRefundPaymentParams
PosRefundPaymentParams
foragePinEditText
: A reference to aForagePINEditText
component.paymentRef
: A unique string identifier for a previously createdPayment
in Forage's database, returned by the Create a Payment endpoint.amount
: A positive decimal number that represents how much of the original payment to refund in USD. Precision to the penny is supported. The minimum amount that can be refunded is0.01
.reason
: A string that describes why the payment is to be refunded.metadata
: A set of optional, merchant-defined key-value pairs. For example, some merchants attach their credit card processor’s ID for the customer making the refund.
Example refundPayment
request
refundPayment
request// PosRefundViewModel.kt
class PosRefundViewModel : ViewModel() {
var paymentRef: String = ""
var amount: Float = 0.0
var reason: String = ""
var metadata: HashMap? = null
fun refundPayment(foragePinEditText: ForagePINEditText) = viewModelScope.launch {
val refundParams = PosRefundPaymentParams(
foragePinEditText,
paymentRef,
amount,
reason,
metadata,
)
val response = forageTerminalSdk.refundPayment(refundParams)
when (response) {
is ForageApiResponse.Success -> {
// do something with response.data
}
is ForageApiResponse.Failure -> {
// do something with response.errors
}
}
}
}
Defer refund completion to the server
Step 1: Collect a customer’s EBT Card PIN (front-end)
After initializing the Terminal SDK and creating a Forage Element, pass PosDeferPaymentRefundParams
to the deferPaymentRefund()
method.
data class PosDeferPaymentRefundParams(
val foragePinEditText: ForagePINEditText,
val paymentRef: String
)
suspend fun deferPaymentRefund(
params: PosDeferPaymentRefundParams
): ForageApiResponse<String>
PosDeferPaymentRefundParams
foragePinEditText
: A reference to aForagePINEditText
component.paymentRef
: A unique string identifier for the previously createdPayment
in Forage's database, returned by the Create aPayment
endpoint when the payment was first created.
Example deferPaymentRefund
request:
// PosDeferPaymentRefundViewModel.kt
class PosDeferPaymentRefundViewModel : ViewModel() {
var paymentRef: String = ""
fun deferPaymentRefund(foragePinEditText: ForagePINEditText) = viewModelScope.launch {
val deferPaymentRefundParams = PosDeferPaymentRefundParams(
foragePinEditText,
paymentRef
)
val response = forageTerminalSdk.deferPaymentRefund(deferPaymentRefundParams)
when (response) {
is ForageApiResponse.Success -> {
// do something with response.data
}
is ForageApiResponse.Failure -> {
// do something with response.errors
}
}
}
Terminal SDK reference docs
Step 2: Complete the Refund (server-side)
Send a POST
to /payments/{payment_ref}/refunds/
to complete the refund. Pass the amount
, reason
, provider_terminal_id
, and metadata
in the body of the request.
curl --request POST \
--url https://api.sandbox.joinforage.app/api/payments/{payment_ref}/refunds/ \
--header 'Authorization: Bearer <authentication_token>' \
--header 'Idempotency-Key: <idempotency-key>' \
--header 'Merchant-Account: mid/<merchant-id>' \
--header 'accept: application/json' \
--header 'content-type: application/json' \
--data '
{
"amount": "<amount>",
"reason": "<reason>",
"pos_terminal": {
"provider_terminal_id": "<provider_terminal_id>"
},
"metadata": {}
}
'
Payments API reference docs
Example Refund and Receipt class
Note that the response object takes the same shape no matter what refund method you choose.
Updated 16 days ago