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.
The screens
Section titled “The screens”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.
The calls
Section titled “The calls”// SigningClient.kt: OkHttp + kotlinx.serializationclass 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 threadviewModelScope.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") }}struct SigningClient { let api: URL // https://api.epahichan.com let publishableKey: String // from your backend let requestId: String let clientSecret: String
struct Session: Decodable { let id: String, status: String, expires_at: String let requested_by: Party, signer: Party let documents: [Document], code: Code } struct Party: Decodable { let name: String; let phone: String? } struct Document: Decodable { let id: String, name: String, purpose: String, pages: Int, signed: Bool } struct Code: Decodable { let sent_to: String?, expires_at: String?, attempts_left: Int, resends_left: Int, test_code: String? let requires: [String]?, subscriber_agreement_version: String? var needsPin: Bool { requires?.contains("pin") ?? false } var needsNewPin: Bool { requires?.contains("new_pin") ?? false } } struct APIError: Error, Decodable { let code: String, message: String } private struct ErrorBody: Decodable { let error: APIError }
private func request(_ path: String, method: String = "POST", body: [String: String]? = nil) async throws -> Data { var r = URLRequest(url: api.appendingPathComponent("v1/signing-requests/\(requestId)\(path)")) r.httpMethod = method r.setValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization") r.setValue(clientSecret, forHTTPHeaderField: "ePahichan-Client-Secret") if let body { r.setValue("application/json", forHTTPHeaderField: "Content-Type") r.httpBody = try JSONEncoder().encode(body) } let (data, response) = try await URLSession.shared.data(for: r) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw try JSONDecoder().decode(ErrorBody.self, from: data).error } return data }
private func session(_ path: String, method: String = "POST", body: [String: String]? = [:]) async throws -> Session { try JSONDecoder().decode(Session.self, from: await request(path, method: method, body: method == "GET" ? nil : body)) }
func load() async throws -> Session { try await session("/session", method: "GET") } func accept() async throws -> Session { try await session("/accept") } func resend() async throws -> Session { try await session("/resend-code") } /// `pin`, or on a first signing `newPin` and `agreement`: see `code.requires`. func confirm(code: String, pin: String? = nil, newPin: String? = nil, agreement: String? = nil) async throws -> Session { var body = ["code": code] body["pin"] = pin body["new_pin"] = newPin body["subscriber_agreement_version"] = agreement return try await session("/confirm", body: body) } func decline(reason: String?) async throws -> Session { try await session("/decline", body: reason.map { ["reason": $0] } ?? [:]) } func document(_ id: String) async throws -> Data { try await request("/session/documents/\(id)", method: "GET") }}Show a document with PDFKit: PDFView().document = PDFDocument(data: try await client.document(doc.id)). For the code field, set textContentType = .oneTimeCode so iOS offers the code from the SMS.
// signing_client.dart: package:httpimport 'dart:convert';import 'dart:typed_data';import 'package:http/http.dart' as http;
class SigningError implements Exception { SigningError(this.code, this.message); final String code; final String message; @override String toString() => message;}
class SigningClient { SigningClient({required this.api, required this.publishableKey, required this.requestId, required this.clientSecret});
final String api; // https://api.epahichan.com final String publishableKey; // from your backend final String requestId; final String clientSecret;
Map<String, String> get _headers => { 'Authorization': 'Bearer $publishableKey', 'ePahichan-Client-Secret': clientSecret, 'Content-Type': 'application/json', };
Uri _uri(String path) => Uri.parse('$api/v1/signing-requests/$requestId$path');
Future<Map<String, dynamic>> _session(String path, {Map<String, String>? body, bool get = false}) async { final r = get ? await http.get(_uri(path), headers: _headers) : await http.post(_uri(path), headers: _headers, body: jsonEncode(body ?? {})); final json = jsonDecode(r.body) as Map<String, dynamic>; if (r.statusCode >= 400) { final e = json['error'] as Map<String, dynamic>; throw SigningError(e['code'] as String, e['message'] as String); } return json; }
Future<Map<String, dynamic>> load() => _session('/session', get: true); Future<Map<String, dynamic>> accept() => _session('/accept'); Future<Map<String, dynamic>> resend() => _session('/resend-code'); /// [pin], or on a first signing [newPin] and [agreement]: see `code.requires`. Future<Map<String, dynamic>> confirm(String code, {String? pin, String? newPin, String? agreement}) => _session('/confirm', body: { 'code': code, if (pin != null) 'pin': pin, if (newPin != null) 'new_pin': newPin, if (agreement != null) 'subscriber_agreement_version': agreement, }); Future<Map<String, dynamic>> decline([String? reason]) => _session('/decline', body: {if (reason != null) 'reason': reason});
Future<Uint8List> document(String documentId) async => (await http.get(_uri('/session/documents/$documentId'), headers: _headers)).bodyBytes;}Show a document with any PDF viewer that takes bytes, such as pdfrx’s PdfViewer.data(bytes, sourceName: doc['name']). For the code, TextField(keyboardType: TextInputType.number, autofillHints: const [AutofillHints.oneTimeCode], maxLength: 6); for the PIN, the same with obscureText: true and maxLength: 8. The epahichan Flutter package does all of this: CodeState.needsPin, needsNewPin and subscriberAgreementVersion, and confirm(code, pin:, newPin:, subscriberAgreementVersion:).
Things to get right
Section titled “Things to get right”- Say what is being signed. Show
requested_by.nameand 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
confirmand 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; ifcompleted, 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.