Loan system guide
This guide wires a loan automation system to the signing API. The customer has been through the bank’s KYC, the loan is approved, and the bank wants two signatures from the customer: one on the certificate application form (Schedule 5), which gives them their digital signature, and one on the loan agreement. The customer signs in the bank’s mobile app; the banking app guide covers that side.
Loan system ePahichan Bank app │ upload Schedule 5, agreement │ │ ├──────────────────────────────▶│ │ │ create signing request │ │ ├──────────────────────────────▶│ │ │ id, client_secret │ │ │◀──────────────────────────────┤ │ │ client_secret (your own API) │ ├───────────────────────────────────────────────────────────────▶│ │ │ show documents, Accept │ │ │◀──────────────────────────────┤ │ │ code by SMS to the customer │ │ │ confirm with the code │ │ │◀──────────────────────────────┤ │ webhook: signing_request.completed │ │◀──────────────────────────────┤ │ │ download both signed PDFs │ │ ├──────────────────────────────▶│ │1. When the loan is approved
Section titled “1. When the loan is approved”Generate the two PDFs as you do today: the Schedule 5 form filled from the customer’s KYC, and the loan agreement. Then upload them and create the request. Use the loan’s own id in the Idempotency-Key and the reference, so a retried job never makes a second request and you can always find the request from the loan.
const API = process.env.EPAHICHAN_API; // https://api.epahichan.comconst KEY = process.env.EPAHICHAN_SECRET_KEY; // sk_test_... or sk_live_...
async function epahichan(path, { method = 'GET', body, headers = {} } = {}) { const response = await fetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${KEY}`, ...headers }, body, }); const json = await response.json(); if (!response.ok) { const e = new Error(json.error.message); Object.assign(e, { status: response.status, code: json.error.code, param: json.error.param }); throw e; } return json;}
const upload = (pdf) => epahichan('/v1/files', { method: 'POST', body: pdf, headers: { 'Content-Type': 'application/pdf' } });
export async function requestSignatures(loan, customer, schedule5Pdf, agreementPdf) { const [schedule5, agreement] = await Promise.all([upload(schedule5Pdf), upload(agreementPdf)]); const request = await epahichan('/v1/signing-requests', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': `loan-${loan.id}-v${loan.documentVersion}` }, body: JSON.stringify({ signer: { name: customer.fullName, phone: customer.mobile, // the KYC-verified number identity: { type: 'citizenship', number: customer.citizenshipNumber, issuing_office: customer.citizenshipDistrict, issued_on: customer.citizenshipIssuedOn, // YYYY-MM-DD }, }, documents: [ { file: schedule5.id, name: 'Certificate application', purpose: 'certificate_application', signature: { page: 2, x: 330, y: 690, width: 200, height: 60 } }, { file: agreement.id, name: `Loan agreement ${loan.number}`, signature: { page: -1, x: 330, y: 700, width: 200, height: 60 } }, ], reference: loan.number, metadata: { loan_id: String(loan.id), branch: loan.branchCode }, expires_in: 7 * 24 * 3600, }), }); // Keep both: the id to follow the request, the client secret for the app. await db.loans.update(loan.id, { signingRequestId: request.id, signingClientSecret: request.client_secret, signingStatus: request.status }); return request;}import os, requests
API = os.environ["EPAHICHAN_API"]KEY = os.environ["EPAHICHAN_SECRET_KEY"]session = requests.Session()session.headers["Authorization"] = f"Bearer {KEY}"
class EpahichanError(Exception): def __init__(self, status, error): super().__init__(error["message"]) self.status, self.code, self.param = status, error["code"], error.get("param")
def call(method, path, **kwargs): r = session.request(method, f"{API}{path}", **kwargs) body = r.json() if not r.ok: raise EpahichanError(r.status_code, body["error"]) return body
def upload(pdf: bytes): return call("POST", "/v1/files", data=pdf, headers={"Content-Type": "application/pdf"})
def request_signatures(loan, customer, schedule5_pdf: bytes, agreement_pdf: bytes): schedule5, agreement = upload(schedule5_pdf), upload(agreement_pdf) request = call( "POST", "/v1/signing-requests", headers={"Idempotency-Key": f"loan-{loan.id}-v{loan.document_version}"}, json={ "signer": { "name": customer.full_name, "phone": customer.mobile, "identity": { "type": "citizenship", "number": customer.citizenship_number, "issuing_office": customer.citizenship_district, "issued_on": customer.citizenship_issued_on.isoformat(), }, }, "documents": [ {"file": schedule5["id"], "name": "Certificate application", "purpose": "certificate_application", "signature": {"page": 2, "x": 330, "y": 690, "width": 200, "height": 60}}, {"file": agreement["id"], "name": f"Loan agreement {loan.number}", "signature": {"page": -1, "x": 330, "y": 700, "width": 200, "height": 60}}, ], "reference": loan.number, "metadata": {"loan_id": str(loan.id), "branch": loan.branch_code}, }, ) loan.signing_request_id = request["id"] loan.signing_client_secret = request["client_secret"] loan.signing_status = request["status"] loan.save() return request// LoanSigning.java (Java 17, java.net.http, Jackson)import com.fasterxml.jackson.databind.*;import java.net.URI;import java.net.http.*;import java.util.Map;import java.util.List;
public final class LoanSigning { private static final String API = System.getenv("EPAHICHAN_API"); private static final String KEY = System.getenv("EPAHICHAN_SECRET_KEY"); private final HttpClient http = HttpClient.newHttpClient(); private final ObjectMapper json = new ObjectMapper();
private JsonNode send(HttpRequest.Builder request) throws Exception { HttpResponse<String> r = http.send(request.header("Authorization", "Bearer " + KEY).build(), HttpResponse.BodyHandlers.ofString()); JsonNode body = json.readTree(r.body()); if (r.statusCode() >= 400) throw new IllegalStateException(body.at("/error/code").asText() + ": " + body.at("/error/message").asText()); return body; }
String upload(byte[] pdf) throws Exception { return send(HttpRequest.newBuilder(URI.create(API + "/v1/files")) .header("Content-Type", "application/pdf") .POST(HttpRequest.BodyPublishers.ofByteArray(pdf))).get("id").asText(); }
JsonNode requestSignatures(Loan loan, Customer customer, byte[] schedule5Pdf, byte[] agreementPdf) throws Exception { var body = Map.of( "signer", Map.of( "name", customer.fullName(), "phone", customer.mobile(), "identity", Map.of("type", "citizenship", "number", customer.citizenshipNumber(), "issuing_office", customer.citizenshipDistrict(), "issued_on", customer.citizenshipIssuedOn().toString())), "documents", List.of( Map.of("file", upload(schedule5Pdf), "name", "Certificate application", "purpose", "certificate_application", "signature", Map.of("page", 2, "x", 330, "y", 690, "width", 200, "height", 60)), Map.of("file", upload(agreementPdf), "name", "Loan agreement " + loan.number(), "signature", Map.of("page", -1, "x", 330, "y", 700, "width", 200, "height", 60))), "reference", loan.number()); return send(HttpRequest.newBuilder(URI.create(API + "/v1/signing-requests")) .header("Content-Type", "application/json") .header("Idempotency-Key", "loan-" + loan.id() + "-v" + loan.documentVersion()) .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))); }}If the customer already holds a certificate (they signed through ePahichan before), leave the application form out: the request is refused with signer_already_certified if you include it, and with certificate_application_required if you leave it out for someone who holds none. A simple rule works: send the application form, and on signer_already_certified create the request again without it.
Where the signature goes. x and y are PDF points (1/72 inch) from the top-left corner of the page, the way you would measure them on a printout. An A4 page is 595 × 842 points. A place that does not fit is refused when you create the request (signature_placement_invalid), never later when the customer accepts.
2. Give the app its client secret
Section titled “2. Give the app its client secret”The bank’s app never holds your secret key. It asks your own backend for the loan’s signing request, and your backend answers with the request id, the client_secret and your publishable key, after checking that the customer signed in to the app is the loan’s customer.
// GET /api/loans/:id/signing, behind your app's own authenticationapp.get('/api/loans/:id/signing', requireCustomer, async (req, res) => { const loan = await db.loans.find(req.params.id); if (!loan || loan.customerId !== req.customer.id) return res.sendStatus(404); res.json({ signing_request: loan.signingRequestId, client_secret: loan.signingClientSecret, publishable_key: process.env.EPAHICHAN_PUBLISHABLE_KEY, });});The client secret lets the app act as the signer for that one request. It is still not enough to sign: that needs the code sent to the customer’s phone.
3. Listen for the outcome
Section titled “3. Listen for the outcome”Add an endpoint under Signing → Webhooks in the console and keep its signing secret. Check every delivery’s signature before trusting it, then act on the event. Deliveries can repeat, so make the handler safe to run twice.
import crypto from 'node:crypto';import express from 'express';
function verified(rawBody, header, secret) { const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const given = String(parts.v1 ?? ''); return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));}
app.post('/epahichan/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { if (!verified(req.body.toString('utf8'), req.get('ePahichan-Signature'), process.env.EPAHICHAN_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body); if (await db.processedEvents.has(event.id)) return res.sendStatus(200);
const request = event.data.object; const loan = await db.loans.findBySigningRequest(request.id); switch (event.type) { case 'signing_request.completed': await storeSignedDocuments(loan, request); // step 4 await loans.markSigned(loan, request.completed_at); break; case 'signing_request.declined': await loans.markDeclined(loan, request.decline_reason); break; case 'signing_request.expired': case 'signing_request.failed': await loans.needsNewSigningRequest(loan, request.failure_code ?? 'expired'); break; } await db.processedEvents.add(event.id); res.sendStatus(200);});import hmac, hashlib, json, os, timefrom flask import Flask, request, abort
def verified(raw_body: bytes, header: str, secret: str) -> bool: try: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) except (ValueError, KeyError, AttributeError): return False if abs(time.time() - t) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/epahichan/webhooks")def webhook(): if not verified(request.get_data(), request.headers.get("ePahichan-Signature", ""), os.environ["EPAHICHAN_WEBHOOK_SECRET"]): abort(400) event = json.loads(request.get_data()) if ProcessedEvent.exists(event["id"]): return "", 200 signing_request = event["data"]["object"] loan = Loan.by_signing_request(signing_request["id"]) if event["type"] == "signing_request.completed": store_signed_documents(loan, signing_request) loan.mark_signed(signing_request["completed_at"]) elif event["type"] == "signing_request.declined": loan.mark_declined(signing_request["decline_reason"]) elif event["type"] in ("signing_request.expired", "signing_request.failed"): loan.needs_new_signing_request(signing_request.get("failure_code") or "expired") ProcessedEvent.add(event["id"]) return "", 200// Spring Boot@PostMapping(path = "/epahichan/webhooks", consumes = "application/json")public ResponseEntity<Void> webhook(@RequestBody byte[] raw, @RequestHeader("ePahichan-Signature") String header) throws Exception { if (!verified(raw, header, System.getenv("EPAHICHAN_WEBHOOK_SECRET"))) return ResponseEntity.badRequest().build(); JsonNode event = json.readTree(raw); if (processed.contains(event.get("id").asText())) return ResponseEntity.ok().build(); JsonNode request = event.at("/data/object"); switch (event.get("type").asText()) { case "signing_request.completed" -> loans.markSigned(request.get("id").asText(), request); case "signing_request.declined" -> loans.markDeclined(request.get("id").asText(), request.path("decline_reason").asText(null)); case "signing_request.expired", "signing_request.failed" -> loans.needsNewSigningRequest(request.get("id").asText()); default -> {} } processed.add(event.get("id").asText()); return ResponseEntity.ok().build();}
static boolean verified(byte[] raw, String header, String secret) throws Exception { Map<String, String> parts = new HashMap<>(); for (String p : header.split(",")) { String[] kv = p.split("=", 2); if (kv.length == 2) parts.put(kv[0], kv[1]); } long t = Long.parseLong(parts.getOrDefault("t", "0")); if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((t + ".").getBytes(StandardCharsets.UTF_8)); String expected = HexFormat.of().formatHex(mac.doFinal(raw)); return MessageDigest.isEqual(expected.getBytes(), parts.getOrDefault("v1", "").getBytes());}4. Keep the signed documents
Section titled “4. Keep the signed documents”On signing_request.completed, download each document from its content_url and keep it with the loan. Check the hash you received against the file you stored.
async function storeSignedDocuments(loan, request) { for (const doc of request.documents) { const response = await fetch(`${API}${doc.content_url}`, { headers: { Authorization: `Bearer ${KEY}` } }); const pdf = Buffer.from(await response.arrayBuffer()); const sha256 = crypto.createHash('sha256').update(pdf).digest('hex'); if (sha256 !== doc.signed_sha256) throw new Error(`hash mismatch for ${doc.id}`); await documentStore.put(`loans/${loan.id}/${doc.purpose}-${doc.id}.pdf`, pdf); }}Keep the request itself too (GET /v1/signing-requests/{id}): its verification block records when the code was sent, how many tries it took and when it was verified. That is your evidence that the customer, not the bank, accepted.
Verify a signed PDF
Section titled “Verify a signed PDF”For a signed PDF you receive back, from a customer or another bank, send it to POST /v1/verifications (the body is the PDF). Nothing is stored. For each signature it answers whether it is intact (the bytes it covers are unchanged and the signature verifies), who signed (signer), and where the certificate stands now on its issuer’s revocation list: good, revoked, suspended, or unknown where no list could be used. A certificate revoked after the document was signed does not undo the signature; status_since says when it changed.
const report = await epahichan.verifications.verify(pdfBytes);if (!report.all_intact) throw new Error('A signature is not intact: do not rely on this document.');for (const s of report.signatures) console.log(s.signer, s.certificate?.status);5. When something goes wrong
Section titled “5. When something goes wrong”| Event or status | What happened | What to do |
|---|---|---|
signing_request.declined |
The customer tapped Reject. decline_reason has their reason, if given. |
Follow your own process; the decline is kept as evidence. |
signing_request.expired |
Nobody acted before expires_at. |
Create a new request, with a new Idempotency-Key. |
signing_request.failed, code_attempts_exhausted |
Five wrong codes. Nothing was signed. | Create a new request. Consider a call to the customer first. |
signing_request.failed, signing_failed |
Signing could not be completed. Nothing was signed. | Create a new request; tell us the request id if it repeats. |
signer_identity_mismatch on create |
The phone number belongs to a signer with a different identity document. | Check the KYC record. |
6. Reconcile
Section titled “6. Reconcile”Webhooks can arrive late or out of order. A nightly job that lists open requests closes any gap:
curl "$EPAHICHAN_API/v1/signing-requests?status=awaiting_code&limit=100" \ -H "Authorization: Bearer $EPAHICHAN_SECRET_KEY"curl "$EPAHICHAN_API/v1/signing-requests?reference=LN-2026-0042" \ -H "Authorization: Bearer $EPAHICHAN_SECRET_KEY"Page on with starting_after=<last id> while has_more is true.
7. Go live
Section titled “7. Go live”Test the whole flow in test mode, including a decline, an expiry and five wrong codes (Testing). Then complete Going live in the console. Your production organization gets its own keys and webhook endpoints; nothing from test mode carries over.