TurnKey ← EasyPay — Cash-Office Repayment Integration Brief¶
Integration instructions for TurnKey Lender, who owns the payment-provider integration under the Kaptl × TurnKey contract. Prepared by Kaptl; 16 July 2026, revised 31 July 2026, revised again 8 September 2026 after the protocol was run against the live demo host.
Read §3a first — demo.epay.bg is two systems
The demo host runs two applications with separate merchant registries.
A merchant registered on one is unknown on the other, and the error it
returns is the same one it returns for a bad checksum. Every earlier
revision of this brief documented the legacy root only. If you have been
building against /ezp/reg_bill.cgi with a merchant registered through
/xdev/web, that is why nothing works.
1. Scope & ownership¶
This brief covers inbound repayment in cash at an EasyPay office — the borrower walks in, quotes a payment code, and pays their installment over the counter. It is the mirror image of the Mass Disbursement brief, which the mass-disbursement brief explicitly left out of scope.
| Disbursement (mass-disbursement brief) | Repayment (this brief) | |
|---|---|---|
| Direction | Lender pays out to borrower | Borrower pays in to lender |
| EasyPay product | Money Send (/ezp/send.cgi) |
Payment via EasyPay — 10-digit collection code |
| Money at the office | Recipient collects cash | Borrower hands over cash |
Per the signed SOW, TurnKey integrates EasyPay server-side; the LOS front office only registers the code and reconciles the payout. It never calls EasyPay directly. IRIS PayByLink/QR (bank repayment) is a separate rail and not covered here.
2. End-to-end flow¶
sequenceDiagram
participant BO as TurnKey Back Office (LOS)
participant TK as TurnKey Server
participant EP as EasyPay (collection)
participant B as Borrower @ office
BO->>TK: Repay loan L, installment N (or early payoff)
TK->>EP: register obligation (ENCODED+CHECKSUM) → 10-digit code
EP-->>TK: payment code + expiry
TK-->>BO: show code + payable-at window
Note over B: borrower goes to any EasyPay office, quotes the code
B->>EP: pays cash
EP->>TK: notification (ENCODED+CHECKSUM) STATUS=PAID
TK-->>EP: ack OK / ERR / NO
Note over TK: PAID → apply payment to schedule<br/>EXPIRED → drop code, borrower re-requests
3a. The two application roots — verified 8 September 2026¶
demo.epay.bg runs a legacy application at the bare host and a current
one under /xdev/web. They have separate merchant registries.
| Legacy | Current | |
|---|---|---|
| Portal | https://demo.epay.bg/ |
https://demo.epay.bg/xdev/web/front |
| Registration | via demo.epay.bg/ |
https://demo.epay.bg/xdev/web/registration/demo |
| Register a code | /ezp/reg_bill.cgi |
/xdev/web/ezp/reg_bill.cgi |
| Extend / invalidate | not served | /xdev/web/paylogin/request/update |
| Payment simulator | /ezp/pay_bill.cgi |
not found |
| Notification URL | not exposed in the profile | PING_URL, set at registration |
What was observed, and why the errors mislead:
- Legacy MIN
D093454823→ the update endpoint →EPLG_NO_MERCHANT. The identical response comes back for a wrong MIN and for an invalidCHECKSUM, so the response does not diagnose the request. - No envelope at all →
STATUS=ERR ERR=Bad input.— a different error, which is what proved a well-formed request was being sent. - Legacy logins are rejected at
/xdev/web/login. - Current MIN
D431857655→ legacy/ezp/reg_bill.cgi→ERR=EMETHOD: Не е намерен валиден търговец! - Current MIN → the update endpoint →
BILL_NOT_FOUND, notEPLG_NO_MERCHANT. The merchant resolved; only the invoice was missing. Telling those two errors apart is the difference between "wrong system" and "right system, unknown code". - Full cycle on the current system: register invoice 70000003 →
IDN=3311359271; extend →STATUS=OK; past-datedEXP_TIME→STATUS=OK; extend again →STATUS=OK. /ezp/pay_bill.cgiwith an IDN from the other system returnedSTATUS=OK IDN= MERCHANT= AMOUNT= DESCR=— a success token with every field empty. Nothing was paid. RequireIDNto come back, and to match the code asked about, before settling anything.- Re-submitting a consumed
INVOICEreturns the already-boundIDN, pending or paid, and reports success — confirmed against the merchant's request list. A collision is silent. On a shared merchant record two parties can both believe they registered an obligation when only one did; partition theINVOICEnamespace rather than relying on an error.
Success is reported in more than one way. The documented token is
SYS_CODE=OK. A registration answers with the code itself (IDN=), and the
live update endpoint answers STATUS=OK. Accept all three — treating only the
documented one as success reads every successful invalidation as a failure.
Two errors you will hit, and what they mean¶
Verified 8 September 2026 against both roots.
EPLG_ID — the INVOICE is already registered on this merchant with
different data. The number is spent; take the next one. Note the roots
differ here, and the difference matters more than the error does:
| re-submit a consumed INVOICE with a DIFFERENT amount | |
|---|---|
legacy /ezp/ |
OK, returns the code bound to the original amount |
current /xdev/web |
EPLG_ID — refused |
The legacy behaviour is a success token for an amount the platform is not
holding: register €42.50 against a number already bound to €5.00 and you are
told it worked. We hit exactly that between two of our own services on one
merchant. An identical re-submission is fine on both roots and returns the
same IDN — the documented retry-safety is unaffected.
BAD_EXP_TIME — the expiry endpoint accepts ONE format, and it is not the
one the specification writes:
"29.09.2026" BAD_EXP_TIME ← a bare date (reg_bill.cgi accepts this)
"29.09.202612:00:00" BAD_EXP_TIME ← DD.MM.YYYY[hh:mm[:ss]], as documented
"29.09.2026 12:00" BAD_EXP_TIME ← without seconds
"29.09.2026 12:00:00" STATUS=OK ← space, and full seconds
Registration takes the bare date; extend and invalidate need the full timestamp with a space. The error names nothing that would lead you there.
Not verified — do not encode as fact¶
- Whether production splits the same way between
www.epay.bg/ezp/...andwww.epay.bg/v3main. - Whether invalidation is terminal. Re-extending after a past-dated
EXP_TIMEreturnedOK; it was not confirmed the code becomes payable again. - Whether
send.cgi(Money Send) has a/xdev/webcounterpart. - How payment simulation works on the current system.
The legacy simulator is not a settlement oracle¶
Two things about /ezp/pay_bill.cgi, both observed 8 September 2026, that
matter if you use it to test reconciliation:
- Paying the same code twice answers
STATUS=OKtwice, identically (IDN 1616889398, €7.77 both times). A settled bill is not refused, so a secondOKcannot be distinguished from a second genuine payment. Idempotency has to live in your ledger. - It serves any bill on the host.
IDN=9999999999— never issued by our merchant — returnedSTATUS=OK MERCHANT=IVT Web Media AMOUNT=13.00with a stranger's description. It echoes the identifier you asked about, so checking the returnedIDNalone does not catch it; check theMERCHANTname against your own.
3. Authentication & signing¶
Same EasyPay merchant identity as Money Send — no separate contract: the
same CIN + 64-char SECRET (auth model). Every request
and every notification uses standard ePay signing:
ENCODED= Base64 (RFC 3548,EOL='') of the newline-joinedKEY=VALUEpayload.CHECKSUM=hmac_sha1_hex(ENCODED, SECRET).
4. Registering the collection code¶
The borrower repays against a registered obligation carrying a 10-digit
identification code — the WEB API's "payment via EasyPay". TurnKey registers one
per repayment intent via GET <root>/ezp/reg_bill.cgi (the standard
ENCODED+CHECKSUM envelope; payload is the EasyPayBillPayload model). Full
request/response is on the EasyPay API Swagger page.
<root> is the application root the merchant is registered on — see §3a. On the
demo host that is https://demo.epay.bg/xdev/web for a merchant registered
through /xdev/web/registration/demo. Registration, invalidation and the portal
must all use the same root.
Field (ENCODED payload) |
Required | Notes |
|---|---|---|
MIN |
Yes | Merchant CIN. |
INVOICE |
Yes | Digits, unique to merchant — use it as the idempotency key. Confirmed on the collection flow 8 Sep 2026: re-submitting a consumed INVOICE returns the already-bound IDN, pending or paid, and reports success. The collision is therefore SILENT; on a shared merchant record, partition the namespace between parties. |
AMOUNT |
Yes | > 0.01, exactly two decimals or none. Live behaviour 8 Sep 2026: 12, 12.00, 99999.99 and 500000.01 accepted; 12.345, 12,50, 0.00, -5.00 and 600000.00 refused. |
EXP_TIME |
Yes | Payment end date/time, DD.MM.YYYY[hh:mm[:ss]]; on lapse the code expires. reg_bill.cgi does not enforce the 30-day ceiling — +90d and +400d were accepted unclamped 8 Sep 2026. Only a past date is refused. Enforce the limit yourselves. |
CURRENCY |
No | On the demo merchant, EUR or omitted. BGN and USD are REFUSED (8 Sep 2026) — the published "defaults to BGN" no longer describes this registry. Send EUR explicitly. |
DESCR |
No | ≤100 chars — human-readable reference (loan PublicId + period). Over 100 is silently truncated, not refused (201 chars came back as 100). Cyrillic round-trips intact with or without ENCODING. |
EMAIL |
No | Merchant email. |
ENCODING |
No | utf-8 for a non-CP1251 DESCR. |
A successful call returns the 10-digit code (IDN) the borrower quotes at the
office.
INPUT_ERROR — a rejected field, and it names nothing¶
Every refusal in the table above comes back as the bare token INPUT_ERROR:
no STATUS, no ERR, no field name. It is not the same answer as
STATUS=ERR ERR=Bad input., which is what an unparseable envelope returns —
if you get INPUT_ERROR, the envelope and the checksum were accepted and a
value inside was not. Classify the two separately or you will go looking for a
signing bug in a correctly signed request.
INVOICE itself has no shape the platform enforces: 4, 12 and 20 digits,
leading zeros and 1000930A were all accepted; only surrounding whitespace was
refused. Leading zeros are significant — 9303 and 00009303 are two
different obligations. Any numbering convention, including the range split
between us, is a convention and nothing upstream defends it.
One code, one obligation. The code is bound to a fixed AMOUNT and a fixed
EXP_TIME. Each installment, each agreed partial payment and each early-payoff
figure is therefore a separate registration — there is no persistent
per-borrower code. An 18-month loan generates at least eighteen codes, and more
once expiries, partials and payoff quotes are counted.
5. Notification & reconciliation¶
On payment (or expiry) EasyPay POSTs a signed notification to TurnKey's
registered URL — the shared NotificationPayload
(INVOICE:STATUS:PAY_TIME:STAN:BCODE, STATUS ∈ {PAID, DENIED, EXPIRED}). Ack
each one:
STATUS=PAID→ apply the payment to the loan schedule (installment N settled, or early payoff), then ackINVOICE=…:STATUS=OKso EasyPay stops resending.STATUS=EXPIRED→ the code lapsed uncollected (EXP_TIMEpassed); drop it and let the borrower request a fresh one. Ack…:STATUS=OK.STATUS=DENIED→ the collection attempt was rejected; do not apply a payment. Ack…:STATUS=OKand surface it for follow-up.- Unknown
INVOICE→ ack…:STATUS=NO. - Transient failure applying the payment → ack
…:STATUS=ERRso EasyPay retries.
The colon-line notification shape, the
OK/ERR/NOack semantics and the resend schedule are written up in the KB for Money Send (disbursement §notification) — the bill/collection flow reuses the sameNotificationPayload, so this brief assumes parity. Confirm on onboarding, along with the resend window (the Money Send doc notes a 30-day vs 14-day discrepancy between the internal doc and the public page).
The notification carries INVOICE, not the 10-digit code. Reconciliation
therefore runs entirely on our own reference; the code is borrower-facing display
only and must never be used as a matching key.
Idempotency & guards TurnKey must own:
- Replay-safe application — a resent
PAIDfor an already-appliedINVOICEis a no-op (the simulator returnsreplayed: true). - Overpayment guard — reject/adjust an amount above the installment due or the outstanding balance.
- State guard — only
ACTIVEloans accept a repayment code;SETTLEDand not-yet-disbursed loans are refused. - Map onto TurnKey's own repayment operation so servicing, reports and the
Public API reflect the payment only once EasyPay confirms
PAID.
Currency is EUR.
EasyPayBillPayload.CURRENCYacceptsEURbut defaults toBGN— always setEURexplicitly on registration.
6. Mapping onto the TurnKey schedule¶
Checked against the v7.12 Public API spec (API); the mapping is less direct than it first appears.
RefNum is the reconciliation anchor, and it already exists.
POST /Management/Loans/Repayments accepts a free-text RefNum, and the value
round-trips — it is present on LoanRepayment when the loan is read back. The
EasyPay INVOICE goes here. No new field is required for the identifier that
actually links cash to loan.
There is no installment identifier. LoanDetails.Schedule is an array of
InstallmentDetails, whose properties are Total, Principal, Interest,
PastDue, Fee, Penalty, Leasing, Tax, DownPayment, Other,
Commissions, DueDate, IsClosed and Status — no Id. This is deliberate
rather than an omission: LoanDetails and LoanRepayment both carry Id.
Installments are exposed as a computed projection of the loan, not as addressable
entities, so the only handles available are DueDate and array position.
InstallmentStatus is one of Scheduled, PaidOnTime, PaidEarly, PaidLate,
Missed, Closed.
Repayments post at loan level, not installment level.
POST /Management/Loans/Repayments takes loanid, Date, Amount, RefNum,
Type, Mode, Service, Comments — no installment reference anywhere.
Allocation is performed by the calculation engine's repayment priority, steered
only by Mode (0 Regular, 1 Extra, 2 Payoff, 3 Initial/down-payment).
Type=1 (Cash) is the correct transaction type for an EasyPay counter payment.
Source of the amount to register.
GET /PaymentOptions/RepaymentAmounts/{loanId} returns CurrentInstallment,
BadDebt and Payoff — covering the scheduled-installment and early-payoff
cases directly.
Registration must be scheduled, not pre-generated. Because EXP_TIME is
mandatory and capped, a full amortisation schedule cannot be registered at
disbursement — later codes would expire long before they were needed. TurnKey
should generate each registration on a rolling basis, a configurable number of
days ahead of the relevant due date. Kaptl proposes 10 days, to be confirmed
by the Risk & Credit Manager with the Support & Collections Lead; build it as a
parameter rather than a constant.
Re-issue after expiry. An expired code leaves the obligation outstanding.
Re-registration is a new record against the same due date under a new
INVOICE — the previous reference cannot be reused.
7. Back office¶
Until the borrower app ships in Phase 2, the back office is the only channel through which a borrower receives a payment code. The loan screen therefore needs the active registration — code, amount and expiry — legible enough for an agent to read out over the phone, alongside the full registration history for the loan, including expired and cancelled rows, which is what dispute handling and collections review both depend on.
Most registrations come from the scheduler (§6) and need no human involvement. Two cases fall outside it:
Automatic invalidation. When a loan is paid off through another rail,
restructured, or written off, any live cash codes against it must be killed by
the system, by setting a past EXP_TIME through
<root>/paylogin/request/update — served on the current root only (§3a);
the legacy application does not serve it at all. Without this a borrower can walk into
an EasyPay office and pay against an installment that no longer exists, leaving
Kaptl unwinding cash it should not have received. This is a system behaviour, not
an agent action.
Agent-created registrations. Partial payments are agreed on a call — a borrower offers €120 against a €200 installment — and because the code is amount-bound, the agent needs to create that registration while the borrower is on the line. The same applies to an early-payoff figure on request. Both are permission-controlled; Kaptl will supply the role matrix and amount limits.
Manual expiry extension is a lower priority. Its only real use is a borrower who already holds the digits and slips a few days past expiry: re-registering works but issues different digits, which the borrower may not have to hand. Worth including if it comes cheaply; not worth building if it does not.
8. What Kaptl provides vs. what TurnKey builds¶
| Kaptl provides | TurnKey builds |
|---|---|
EasyPay merchant credentials (CIN/SECRET, sandbox → prod) |
Code registration per repayment intent (installment / partial / early payoff) |
| Loan → amount-due / settlement-figure mapping | Rolling scheduler generating registrations ahead of each due date (§6) |
| Confirmation of the scheduler lead time (proposed 10 days) | Notification receiver (signed) + ack OK/ERR/NO |
| Sign-off on the status → schedule mapping (§5) | Payment application via RefNum + overpayment / replay / state guards |
| Back-office role matrix and amount limits for agent-created registrations | Back-office display of active code, amount, expiry + registration history (§7) |
| Office-network sign-off (informational directory) | Automatic invalidation of live codes on payoff / restructure / write-off |
| Reconciliation sweep for codes left unpaid past expiry |
9. Reference implementation¶
Kaptl's implementation of this protocol now runs against the real ePay demo
merchant, in kaptl-los-prototype → server/lib/easypay/ (ported from
kaptl-easypay-collection), served at /api/easypay. It is the behavioural
reference for everything in §3a: the root check, the three success tokens, the
error kinds, the guard against the empty-IDN success, and the INVOICE
partition. See that repo's docs/EASYPAY.md.
The simulator below predates it and is a pure mock — no envelope, no
checksum, no HTTP call — so it cannot demonstrate any of the failures §3a
describes. It remains useful for shape-level integration testing only
(Simulator, kaptl-los-api-kb → simulator/):
| Simulator endpoint | Models |
|---|---|
GET /easypay/offices |
the EasyPay office directory (sample of the network) |
POST /repayments/easypay/code { loanId, officeId?, period? \| amount? \| early } |
register a collection code → { code, payableAt, amountEur, … } (the response also carries amountBgn and an informational banner — ignore both; EUR is the working currency) |
POST /repayments/easypay/pay { code } |
models the borrower paying cash → applies the payment, idempotent (replayed) |
The LOS prototype's Tools → Office repayment console no longer drives the
simulator: it calls the prototype's own proxy, which registers codes on the real
merchant. It has no "settle" button, because a code settles when the borrower
pays at a counter and EasyPay notifies the merchant — and because of fact (7)
in §3a, a simulated settlement can return success while settling nothing. See
kaptl-los-prototype → src/api/easypay.ts and docs/EASYPAY.md.
10. Open items — [confirm on onboarding]¶
- The application root (§3a) — which merchant record is the one to build against, and whether production splits the same way. Everything else in this list is secondary to it.
EXP_TIMEpolicy — how long an unpaid code stays payable — and the notification retry schedule onERRacks (Money Send doc says 30 days, public page 14). Confirm the bill/collection flow uses the same notification colon-line +OK/ERR/NOack as Money Send (assumed parity, §5).CURRENCY=EURoperationally confirmed with EasyPay (accepted by the spec; default isBGN).- Office choice is informational —
EasyPayBillPayloadhas no office field, so the code is payable at any EasyPay office. Confirm no separate steering mechanism is expected (the simulator accepts anofficeIdbut does not constrain collection). - Reminder-SMS trigger (via VertexSMS) the day before a due date, carrying the payment code — align with TurnKey's notification engine. With no borrower app in Phase 1, this and the back-office screen (§7) are the only two routes by which a borrower obtains the digits.
- Installment identity (§6) — a registration needs to be tied to a specific
scheduled installment, but the Public API exposes no installment
Id. Confirm whether TurnKey holds an internal identifier that can be surfaced, or whether the link should be modelled asloanId+DueDate. - Scheduler lead time — confirm the configurable number of days ahead of due date at which registrations are generated (Kaptl proposes 10).
- The INVOICE split. Kaptl issues below
10500000; TurnKey from10500001. Agreed by email 8 September 2026 and now enforced in Kaptl's implementation, which refuses to allocate into TurnKey's half. Please confirm it is enforced on your side too — on the legacy root a collision does not error, and on the current one it surfaces asEPLG_ID, which reads like a bug rather than a boundary.