llms.txt Changed How I Integrate APIs: A Reton Case Study With Interswitch VAS
Wallet Versus Rail
Reton is the digital wallet: balances, Callback Protection, recovery, and the double-entry ledger. Interswitch is the payments partner: cards, transfers, and bill payments underneath. It is not a bank. It is not Reton's ledger.
The only call chain I accept for bills:
Next.js /bills
→ POST /api/v1/bills/pay (BillController: HTTP only)
→ BillPayService
→ PIN, KYC limits, fraud
→ LedgerService.debitAvailable
→ InterswitchGatewayInterface.payBill
→ LedgerService.creditAvailable (clear rail decline only)
→ { "data": ... } or { "error": { "code", "message" } }
I will not take provider HTTP from a controller or from the browser, mutate Wallet balances outside LedgerService, or grant success because a query string contains resp=00. Amounts on the ledger are kobo integers. The web app sends naira. The API converts and rejects bills under โฆ100.
What llms.txt Actually Is
Interswitch publishes a documentation index at docs.interswitchgroup.com/llms.txt. It is a plain-text catalogue: titles, links, and short descriptions. It is not sitemap XML, not a blog post, and not a substitute for OpenAPI.
The same file tells you to append .md to a documentation URL to receive the page as Markdown. Those two conventions are instructions for humans and for models. I treat them as part of the integration. I do not paste the whole index into every prompt. I fetch it once, pick a short list of URLs from the catalogue, then fetch those as .md.
Without the index, an agent searches the open web, mixes Quickteller versions, and invents paths with a straight face. With the index, the agent starts from Interswitch's own catalogue. HTML documentation pages are built for browsers: navigation, scripts, theme wrappers. Models spend tokens on chrome. Markdown pages are built for reading. I stopped pasting HTML URLs into AI chats for Interswitch work.
How I Actually Pull the Docs
I keep a vendor-notes folder, not a second brain for the model.
mkdir -p docs/vendor-notes/interswitch
curl -fsSL https://docs.interswitchgroup.com/llms.txt \
-o docs/vendor-notes/interswitch/llms.txt
If the file starts with <!doctype, I did not get the index. I stop. I do not "remember Interswitch VAS" from training data and keep going.
Then I search the saved file for VAS language (bill, airtime, biller, voucher) and write matching Markdown URLs to a short list. For Reton that list is usually:
- Value Added Services overview
- Bills payment
- Airtime recharge, virtual top-up
- Airtime e-pins, only if we are actually issuing pins
- Get billers, billers by category, biller payment item, biller categories
- Customer validation
- Create bill
- VAS response codes
I copy those URLs from the saved index. Interswitch can rename a slug. The customer validation page in the live catalogue is customer-validation-1.md, not a guessed customer-validation.md. There is no query-transaction page in that index. Status for our funding requery lives under collections (transaction requery, get transaction status), which is a different product surface than Quickteller VAS.
I also keep send-money, card checkout, and the payments-section Pay Bill page out of a bills prompt. Pay Bill in the card/checkout guides is not Create Bill in VAS. The Bank API bills guide is written for banks. Reton is not a bank. Mixing those in one chat is how the agent wires the wrong endpoint.
Each fetch looks like this:
curl -fsSL \
"https://docs.interswitchgroup.com/docs/value-added-services-overview.md" \
-o docs/vendor-notes/interswitch/pages/vas-overview.md
The first heading should be the document title. If I see a theme shell, I refetch with .md. I attach one guide and one reference for the current task. Electricity this week means the bills guide plus customer validation plus payment item, not the e-pins guide.
Before any PHP, I make the agent fill a field sheet and cite the saved filename on every row: payment code, customer id, amount, request reference, terminal id. If it cannot cite a file, the row is not done. Payment codes and terminal ids come from merchant config and secrets, never from the browser, never from git. Discovery and implementation are separate turns.
A Prompt I Actually Use
Read the saved
llms.txtand the.mdpages I attached. Interswitch is a payments company, not a bank, and not Reton's wallet. Reton owns balances and the double-entry ledger. Do not search the public web for Interswitch VAS. Do not invent paths. Do not write gateway code in this turn.
When implementation starts, I name the files it may touch:
-
backend/src/Wallet/BillPayService.php -
backend/src/Controller/Api/V1/BillController.php -
backend/src/Gateway/Interswitch/InterswitchGatewayInterface.php -
backend/src/Gateway/Interswitch/HttpInterswitchGateway.php -
backend/src/Gateway/Interswitch/NullInterswitchGateway.php backend/src/Ledger/LedgerService.php(read; do not casually rewrite)-
backend/tests/ApiV1Test.php
I refuse unbounded prompts such as "add Interswitch," "make bills like Opay," or "use whatever Quickteller endpoint you remember."
The Money Path
LedgerService is the only code allowed to change wallet balances. Bills are not a new accounting primitive. They are a debit of customer liability, then a rail result.
BillPayment stores wallet, biller category, customer reference, amount in kobo, payment code, and the idempotency key. We look that key up before any debit. If a row exists, we return it. Double-clicks are normal. The ledger cannot absorb them.
After PIN, KYC, and fraud checks, the rest runs inside a Doctrine transaction:
LedgerService::debitAvailablewith kindbill_reserveand journal idbill-reserve:{reference}.InterswitchGatewayInterface::payBill(paymentCode, customerId, amountKobo, reference).- If approved (
ResponseCode 90000on the HTTP gateway): mark completed, audit, return. - If the rail returns a clear decline:
creditAvailablewith kindbill_reverse, mark failed, throwprovider_failed.
The controller never sees Interswitch JSON. The HTTP gateway maps provider fields into { approved, reference, raw_code } and posts to /quickteller/v1/transactions. NullInterswitchGateway always returns 90000 so tests and local UI can move. Empty live credentials must not be described as a live rail. The container still has to bind InterswitchGatewayInterface to the HTTP gateway in production. Compiling HttpInterswitchGateway is not the same as using it.
One Gap I Will Not Paper Over
The first bills cut treats any non-approval, including a transport error, as a decline and reverses. That is safe only when Interswitch has actually said no. A timeout after the HTTP call left our process is not a decline. The correct next cut is to leave the bill pending with the reserve in place, then requery. BillPayment already has a pending status. pay() does not leave a row there yet.
If you later move the HTTP call outside the database transaction, you need that same pending path. Do not "optimise" by deleting the reverse on a documented decline. Audit logs run after the journal. Audit is evidence. It is not the balance.
Testing the Money Path
I want PHPUnit to prove the money path, not only HTTP 201: fund a wallet, POST /api/v1/bills/pay with Idempotency-Key, assert the Null happy path completes, replay the same key with no second debit, then force a provider failure and assert available balance is restored. Those bill cases are not in ApiV1Test yet. A green suite that never touches /bills/pay does not prove this.
The HTTP API VAS on Reton
The HTTP API VAS on Reton sits at /bills.
GET /api/v1/billsreturns our catalogue: airtime, data, electricity, TV, internet. It is not a live proxy of Interswitch's biller directory. The labels exist so the app can render tiles. Payment codes stay on the server.POST /api/v1/bills/payis a money POST.
Gates, in order:
- Authentication. No
User, 401 unauthenticated. There is no public pay endpoint for a nicer demo. - Idempotency-Key. Trim the header. Empty means 422. The Next.js
moneyApi()helper always sends a UUID. The server still rejects a missing header so a future mobile client cannot skip it. - Body.
biller,customer_reference(max 40), positive amount in naira, optionalpin. Convert with(int) round(amount * 100). Reject below 10000 kobo. - PIN. If the user has a transaction PIN, verify it. Failure is 403
invalid_pin, not 500. If they have not set a PIN yet,PinServicedoes not invent a requirement. - KYC.
KycService::assertWithinLimitsapplies. A bill is a money movement. It is not exempt because it is "just airtime." - Fraud.
FraudService::assesswithbill_paymentandamount_kobo. A hard block isfraud_blocked.
Everything maps to { "error": { "code", "message" } }. The browser never receives a raw Interswitch body. On the client, submit through moneyApi, show structured errors, refresh the wallet on success. Never put the PIN in a query string.
Identifiers the Server May Trust
This is where models sound most confident and do the most damage.
Customer Reference
- Airtime and data take an MSISDN.
- Electricity takes a meter number.
- TV takes a smartcard / IUC.
- Internet takes an account id.
Trim, reject empty, cap length. Do not send a meter number down an airtime path because the field was named accountNumber. Electricity and TV should run customer validation before debit. The first Reton slice still forwards the typed string. That is a known gap, not a finished inquiry flow.
Payment Codes
Payment items are issued to your merchant in that environment. They are not global constants. Sandbox codes are not production truth. Reton currently keeps category placeholders in BillPayService::BILLERS (90101 through 90105) so local and test have a string to send. Those values are not live Quickteller items. Production codes come from the Quickteller dashboard for that environment, stored in secrets, keyed by catalogue id. If the map is missing in production, fail closed. Do not send a placeholder to the live rail. If Cursor "remembers" a DSTV code from training data, discard it.
Amounts
Many airtime top-ups are customer-chosen: the user types naira, the server converts to kobo, then applies min/max, KYC, and available balance. Many electricity tokens and bouquet renewals are inquiry-defined: validate the customer, take the item or amount Interswitch returned, and debit that figure. If the JSON body disagrees with the inquiry, reject the pay request.
Reton's first bills slice still accepts body amounts for every category. Electricity and TV should move to inquiry-defined amounts before live volume. I would rather write that gap down than pretend the model closed it.
Request References
The saved bills payment guide is the source for length, charset, and merchant prefix. We generate one value, use it on the ledger (bill-reserve:{reference}), and send it as requestReference on payBill. Unique UUIDs are not the same as accepted rail identifiers. Current Reton references look like BP- plus hex. That is our generator, not a claim that every Quickteller environment will accept it.
The implementation prompt gets these refusals:
- Do not invent payment codes.
- Do not invent
requestReferenceformats. - Do not debit an amount that did not pass server rules.
- If the saved guide is silent, ask. Do not guess.
Requery, Pending, and Frontend Lies
Bills that return a synchronous approve or decline are the easy case. Rails also return pending, drop packets, or send the user through a browser. The rule is the same for VAS, card, and wallet pay:
Never grant wallet value because the frontend said so.
resp=00 in a query string is not a ledger permission. Sources of truth, in order:
- Server requery of Interswitch with the reference you stored.
- A signed webhook, verified before any handler mutates state.
- Your
BillPaymentrow plus ledger journals.
If those disagree with (1) or (2), the journals win for the customer balance, and ops reconciles the rail.
Comments
No comments yet. Start the discussion.