Dev.to WebDev 🛠 Dev 👁 0 📖 13 min read

llms.txt Changed How I Integrate APIs: A Reton Case Study With Interswitch VAS

I build Reton, a trust-first digital wallet for Africa. Our flagship is Callback Protection: hold a payment, release it when you are ready, or raise a callback, with a timeline a person can actually follow. A wallet tha

llms.txt Changed How I Integrate APIs: A Reton Case Study With Interswitch VAS

Reton Homepage

I build Reton, a trust-first digital wallet for Africa. Our flagship is Callback Protection: hold a payment, release it when you are ready, or raise a callback, with a timeline a person can actually follow.

A wallet that only moves money between Reton IDs is incomplete. People still buy airtime, data, electricity, and television. If they have to leave Reton to pay those bills, the balance stops feeling like money they can live on. Value Added Services had to sit on the same ledger we already treat as the source of truth.

I was not looking for a bank. I was looking for a payments rail. Interswitch is a payments company. Reton is the wallet. That sentence is the architecture. AI coding tools keep trying to smear it.

This is how Interswitch's llms.txt, Markdown documentation URLs, and reference pages changed the way I integrate APIs in Cursor, and how Reton still refuses to let a model invent payment behaviour.

The problem

Reton already owned customer wallets, Reton IDs, available and held balances as projections of a double-entry ledger, PIN-gated money movement, protected transfers, callbacks, and wrong-transfer recovery.

What we did not need to rebuild was a nationwide biller directory. Interswitch Quickteller VAS already reaches cable, electricity, telecom airtime and data, and related categories. Using that rail is a product decision. Treating that rail as the wallet is a design error.

The constraint was integration quality under time pressure, while pairing with an AI coding tool. I needed the correct VAS documents (bills payment is not airtime e-pins, and neither is send-money), those documents as clean text rather than a rendered marketing site, and a money path the model was not allowed to invent: debit the Reton ledger inside a database transaction, talk to Interswitch only through a gateway.

The old method was five browser tabs, a paste into chat, and hope the model still remembered which product the last URL belonged to. The better method is a machine-readable map of the documentation site, then one Markdown page at a time.

That map is llms.txt.

Wallet versus rail

If you skip this distinction, you will ship a demo, not a wallet.

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:

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.txt and the .md pages 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:

  1. LedgerService::debitAvailable with kind bill_reserve and journal id bill-reserve:{reference}.
  2. InterswitchGatewayInterface::payBill(paymentCode, customerId, amountKobo, reference).
  3. If approved (ResponseCode 90000 on the HTTP gateway): mark completed, audit, return.
  4. If the rail returns a clear decline: creditAvailable with kind bill_reverse, mark failed, throw provider_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.

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 sits at /bills.

GET /api/v1/bills returns 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/pay is 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, optional pin. 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, PinService does not invent a requirement.

KYC. KycService::assertWithinLimits applies. A bill is a money movement. It is not exempt because it is "just airtime."

Fraud. FraudService::assess with bill_payment and amount_kobo. A hard block is fraud_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 requestReference formats.
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:

  1. Server requery of Interswitch with the reference you stored.
  2. A signed webhook, verified before any handler mutates state.
  3. Your BillPayment row plus ledger journals. If those disagree with (1) or (2), the journals win for the customer balance, and ops reconciles the rail.

The browser is not on this list.

Rail view Bill status Ledger
Approved completed Debit remains
Documented decline failed Reverse posted once
Pending or timeout after debit pending Debit remains; worker requeries
Unknown do not complete Do not reverse until requery says decline

InterswitchGatewayInterface::requery lives on the gateway, not in the controller. On Reton today that method is wired for collections-style status (/collections/api/v1/gettransaction) and used when we settle funding. It is not yet a bills worker selecting unfinished BillPayment rows. I am describing the rule we already enforce for deposits, and the rule bills must inherit before we treat timeouts as declines.

POST /api/v1/webhooks/interswitch is public. We verify X-Interswitch-Signature on the raw body first (HMAC-SHA512), then decode JSON. Unsigned bodies are allowed only when the webhook secret is empty and the kernel is in debug. Production must not run that way. The handlers behind that route currently settle funding and virtual-account inbound, not bills. A VAS webhook that credits a bill without matching a reserved journal would be a new, explicit path, not a side effect I would let the model "reuse."

I reject diffs that credit a wallet in Next.js because searchParams.resp === '00', trust window.location for amount, or skip requery because "the SDK said success."

If a bill is pending, the UI says it is being confirmed. The current bills page says "payment successful" after a 201. That is honest only while pay() refuses to return pending.

What the model got wrong

llms.txt did not cause these mistakes. It made the right documents easy to attach, which made the mistakes easier to catch.

It treated Interswitch as the bank. Early drafts said Interswitch held customer balances. Wrong. Blur that in copy and you will blur it in the domain model.

It invented requestReference formats. The model emitted ULIDs because they look unique. Quickteller bill payments are pickier. Unique is not accepted.

It trusted the client for amounts. Lookup-based bills need a server-side inquiry. The model's first instinct is still "take amount from the body."

It assumed payment codes are universal. Codes are merchant- and environment-specific. A hard-coded code the model "remembers" is a smell.

It mixed product surfaces. Create Bill, Pay Bill, Bank API, and collections requery all mention bills or transactions. They are not interchangeable. The index is how I keep them apart.

What I will not merge

I run this as a human. Models will tick boxes they did not earn.

The saved llms.txt has to be plain text. In-scope pages have to be the .md files from that index, not a send-money or card guide dropped into a bills task. Copy never calls Interswitch a bank, and never describes Reton as only a frontend over Interswitch.

On money: the controller has no provider HTTP, debit happens before payBill, a documented decline reverses the same kobo amount, idempotent replay does not double-debit, PIN and KYC and fraud still run, timeouts are not stored as completed, and no Next.js route grants balance from query params.

Going live is a separate question from "it compiles." Secrets stay out of git. Production payment codes replace 90101-90105. The webhook secret is set and unsigned debug is off. Simulate funding is off. The Null gateway is not the production port.

cd backend && php vendor/bin/phpunit --colors=always

That command is necessary. For bills, it is not sufficient until the suite actually posts /api/v1/bills/pay.

Closing

I used to think "AI-friendly documentation" meant nicer sentences. Interswitch showed me it is closer to infrastructure: an index the agent can fetch, pages it can read without HTML noise, and contracts it can respect.

llms.txt did not replace engineering judgment on Reton. It removed the scavenger hunt, so judgment could stay on the part that matters: real money, moving correctly, for real people.

Start at docs.interswitchgroup.com/llms.txt. Append .md to the VAS, bills, or airtime page you need. Attach the reference page for field shape. Keep your own ledger rules stricter than the model's optimism.

Reton is live at retonpay.com.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.