Skip to content

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:

  1. Legacy MIN D093454823 → the update endpoint → EPLG_NO_MERCHANT. The identical response comes back for a wrong MIN and for an invalid CHECKSUM, so the response does not diagnose the request.
  2. No envelope at all → STATUS=ERR ERR=Bad input. — a different error, which is what proved a well-formed request was being sent.
  3. Legacy logins are rejected at /xdev/web/login.
  4. Current MIN D431857655 → legacy /ezp/reg_bill.cgi → ERR=EMETHOD: Не е намерен валиден търговец!
  5. Current MIN → the update endpoint → BILL_NOT_FOUND, not EPLG_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".
  6. Full cycle on the current system: register invoice 70000003 → IDN=3311359271; extend → STATUS=OK; past-dated EXP_TIME → STATUS=OK; extend again → STATUS=OK.
  7. /ezp/pay_bill.cgi with an IDN from the other system returned STATUS=OK IDN= MERCHANT= AMOUNT= DESCR= — a success token with every field empty. Nothing was paid. Require IDN to come back, and to match the code asked about, before settling anything.
  8. Re-submitting a consumed INVOICE returns the already-bound IDN, 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 the INVOICE namespace 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/... and www.epay.bg/v3main.
  • Whether invalidation is terminal. Re-extending after a past-dated EXP_TIME returned OK; it was not confirmed the code becomes payable again.
  • Whether send.cgi (Money Send) has a /xdev/web counterpart.
  • 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=OK twice, identically (IDN 1616889398, €7.77 both times). A settled bill is not refused, so a second OK cannot 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 — returned STATUS=OK MERCHANT=IVT Web Media AMOUNT=13.00 with a stranger's description. It echoes the identifier you asked about, so checking the returned IDN alone does not catch it; check the MERCHANT name 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-joined KEY=VALUE payload.
  • 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 ack INVOICE=…:STATUS=OK so EasyPay stops resending.
  • STATUS=EXPIRED → the code lapsed uncollected (EXP_TIME passed); 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=OK and surface it for follow-up.
  • Unknown INVOICE → ack …:STATUS=NO.
  • Transient failure applying the payment → ack …:STATUS=ERR so EasyPay retries.

The colon-line notification shape, the OK/ERR/NO ack semantics and the resend schedule are written up in the KB for Money Send (disbursement §notification) — the bill/collection flow reuses the same NotificationPayload, 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 PAID for an already-applied INVOICE is a no-op (the simulator returns replayed: true).
  • Overpayment guard — reject/adjust an amount above the installment due or the outstanding balance.
  • State guard — only ACTIVE loans accept a repayment code; SETTLED and 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.CURRENCY accepts EUR but defaults to BGN — always set EUR explicitly 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]

  1. 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.
  2. EXP_TIME policy — how long an unpaid code stays payable — and the notification retry schedule on ERR acks (Money Send doc says 30 days, public page 14). Confirm the bill/collection flow uses the same notification colon-line + OK/ERR/NO ack as Money Send (assumed parity, §5).
  3. CURRENCY=EUR operationally confirmed with EasyPay (accepted by the spec; default is BGN).
  4. Office choice is informational — EasyPayBillPayload has no office field, so the code is payable at any EasyPay office. Confirm no separate steering mechanism is expected (the simulator accepts an officeId but does not constrain collection).
  5. 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.
  6. 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 as loanId + DueDate.
  7. Scheduler lead time — confirm the configurable number of days ahead of due date at which registrations are generated (Kaptl proposes 10).
  8. The INVOICE split. Kaptl issues below 10500000; TurnKey from 10500001. 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 as EPLG_ID, which reads like a bug rather than a boundary.