Skip to content

Banking app guide

The customer signs inside your app. Your app shows them what they are asked to sign, takes their Accept or Reject, and asks for the code ePahichan sends to their phone. The loan system guide covers creating the request on your server.

Your app holds two things, both from your own backend: your publishable key (pk_test_… or pk_live_…) and the request’s client secret. Every call sends both:

Authorization: Bearer pk_test_...
ePahichan-Client-Secret: sr_..._secret_...

Never put a secret key (sk_…) in an app. A publishable key can only take the signer’s steps below, and only for the request whose client secret it holds.

1. Review. GET /v1/signing-requests/{id}/session tells you who is asking (requested_by.name), the signer’s name, the documents and when the request expires. Show each document with GET …/session/documents/{doc} (a PDF). Two buttons: Accept and sign and Reject.

2. Code. POST …/accept sends a 6-digit code to the customer’s phone and answers with code.sent_to (masked, such as +977 98••••• 567) and code.expires_at. Ask for the code and send it with POST …/confirm. Offer Send another code after a minute (POST …/resend-code).

In live mode the certifying authority checks the code together with the customer’s eSign PIN. code.requires says what to ask for:

code.requires Ask for Send to confirm
[code] The code (test mode) code
[otp, pin] The code and the customer’s eSign PIN code, pin
[otp, new_pin, subscriber_agreement] Their first signing: the code, a PIN they choose (twice), and acceptance of the subscriber agreement code, new_pin, subscriber_agreement_version (from code.subscriber_agreement_version)

A PIN is 6 to 8 digits and not a run (123456) or a repeat (111111). Check that before sending. For a forgotten or locked PIN, offer Forgot your PIN?: POST …/pin-reset texts the customer a code and answers with its id; send id, the code and a new_pin to POST …/pin-reset/confirm, then confirm the signing with the new PIN.

3. Done. confirm answers once every document is signed, with status: completed. Say so, and offer the signed documents from the same session/documents address.

On Reject, POST …/decline with an optional reason.

Answer Show
code_incorrect “That code is not right.” The message says how many tries are left.
code_expired “The code has expired.” Offer another code.
code_attempts_exhausted “Too many wrong codes.” The request has ended; the bank sends a new one.
code_resend_too_soon Wait a minute before offering another code.
pin_required Ask for the PIN (code.requires has pin).
pin_incorrect “That PIN is not right.” The message says how many tries are left. Offer Forgot your PIN?.
account_locked “Too many wrong PINs.” Offer Forgot your PIN?.
new_pin_invalid “Choose a PIN of 6 to 8 digits that is not a run or a repeat.”
subscriber_agreement_required The customer has to accept the subscriber agreement on their first signing.
signing_request_unexpected_state The request was already signed, declined, canceled or expired. Reload the session and show its status.

In test mode, session.code.test_code is 424242, and no SMS is sent. It is null in live mode.

// SigningClient.kt: OkHttp + kotlinx.serialization
class SigningClient(
private val api: String, // https://api.epahichan.com
private val publishableKey: String, // from your backend
private val requestId: String,
private val clientSecret: String,
private val http: OkHttpClient = OkHttpClient(),
) {
private val json = Json { ignoreUnknownKeys = true }
private fun call(method: String, path: String, body: String? = null): Response {
val request = Request.Builder()
.url("$api/v1/signing-requests/$requestId$path")
.header("Authorization", "Bearer $publishableKey")
.header("ePahichan-Client-Secret", clientSecret)
.method(method, body?.toRequestBody("application/json".toMediaType()))
.build()
return http.newCall(request).execute()
}
private fun session(path: String, body: String? = null, method: String = "POST"): Session =
call(method, path, body).use { r ->
val text = r.body!!.string()
if (!r.isSuccessful) throw SigningError(json.decodeFromString<ErrorBody>(text).error)
json.decodeFromString(text)
}
fun load() = session("/session", method = "GET")
fun accept() = session("/accept", "{}")
fun resend() = session("/resend-code", "{}")
// pin, or on a first signing newPin and agreement: see code.requires.
fun confirm(code: String, pin: String? = null, newPin: String? = null, agreement: String? = null) =
session("/confirm", json.encodeToString(buildMap {
put("code", code)
pin?.let { put("pin", it) }
newPin?.let { put("new_pin", it) }
agreement?.let { put("subscriber_agreement_version", it) }
}))
fun decline(reason: String?) = session("/decline", json.encodeToString(mapOf("reason" to reason)))
fun document(documentId: String): ByteArray =
call("GET", "/session/documents/$documentId").use { it.body!!.bytes() }
}
@Serializable data class Session(val id: String, val status: String, val requested_by: Party,
val signer: Party, val documents: List<Doc>, val code: Code, val expires_at: String)
@Serializable data class Party(val name: String, val phone: String? = null)
@Serializable data class Doc(val id: String, val name: String, val purpose: String, val pages: Int, val signed: Boolean)
@Serializable data class Code(val sent_to: String? = null, val expires_at: String? = null,
val attempts_left: Int, val resends_left: Int, val test_code: String? = null,
val requires: List<String> = listOf("code"), val subscriber_agreement_version: String? = null) {
val needsPin get() = "pin" in requires
val needsNewPin get() = "new_pin" in requires
}
@Serializable data class ApiError(val code: String, val message: String)
@Serializable data class ErrorBody(val error: ApiError)
class SigningError(val error: ApiError) : Exception(error.message)
// In your ViewModel: off the main thread
viewModelScope.launch(Dispatchers.IO) {
try {
val c = session.code
val s = client.confirm(
codeInput,
pin = if (c.needsPin) pinInput else null,
newPin = if (c.needsNewPin) newPinInput else null,
agreement = if (c.needsNewPin) c.subscriber_agreement_version else null,
)
if (s.status == "completed") _state.value = Done(s)
} catch (e: SigningError) {
_state.value = CodeScreen(error = e.error.message, exhausted = e.error.code == "code_attempts_exhausted")
}
}
  • Say what is being signed. Show requested_by.name and every document before the customer can accept. The customer is signing all of them with one Accept.
  • Say where the code comes from. In live mode the certifying authority texts it (“Radiant eSign OTP”); in test mode no SMS is sent. The customer should not expect it from the bank.
  • Never store the PIN. Send it once with confirm and clear the field. Only the certifying authority checks it.
  • Never read the code from the SMS yourself other than through the platform’s own one-time-code autofill, which the customer approves.
  • Resume where the customer left off. Load the session when the screen opens: if the status is awaiting_code, go straight to the code screen; if completed, show the done screen.
  • Tell the customer about ePahichan. After signing, they can sign in to the ePahichan app with the same phone number to see their certificate and signed documents.