Skip to content

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 │ │
├──────────────────────────────▶│ │

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.

loan-signing.js
const API = process.env.EPAHICHAN_API; // https://api.epahichan.com
const 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;
}

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.

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 authentication
app.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.

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);
});

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.

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);
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.

Webhooks can arrive late or out of order. A nightly job that lists open requests closes any gap:

Terminal window
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.

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.