The banking API that deliberately will not tell you things
Fineract exposes a restricted self-service namespace rather than the full API. What it omits is a design decision, and it shapes the whole client.
A core banking platform exposes a complete API. A mobile app for the people whose accounts it holds
gets a different one — a restricted namespace under /self/, with its own endpoints, its own shape and
its own omissions.
Treating it as the full API with a prefix is the mistake that costs the first week.
The case this post is about
One endpoint namespace, approached as though it were the full API with a prefix:
namespace /fineract-provider/api/v1/self/ — a surface, not a filter
auth Basic credentials plus a tenant header on every request
identity authenticate as a user, then resolve the client to operate on
templates ask the server what the user may choose, then render the form
The mental model that self/ is the normal API scoped to the current user is wrong in a specific
way: it is a smaller surface with a different shape. Calls that exist in the admin API have no
self-service equivalent, and the ones that do take different parameters.
This post follows that namespace, because the gap between those two models is where all the integration work actually is — and the third line above is the one that cost the most, since authenticating as a user does not tell you which client you are acting for.
The symptom
An endpoint that obviously exists is not there. The platform documents an operation, the Swagger page shows it, and the self-service namespace does not expose it — not as a permissions failure at runtime, which would at least be legible, but as an absence. The shape of the client you were planning around does not have a server behind it.
The second thing is that the omissions are not arbitrary and do not look principled from outside. Some operations are absent because a customer should not perform them at all; some because they require context the authenticated user does not have; some because nobody has needed them yet in a self-service setting. From the integrating side all three present identically, and guessing which you are looking at determines whether you wait, work around, or contribute upstream.

The third is the thing that caught me: the entity you authenticate as is not the entity you query for. A self-service user is associated with one or more clients, so the first call is a list, and every subsequent call carries an identifier from it. That is one extra round trip and one extra piece of state before any feature exists, and it is not visible from the endpoint list.

The fourth is the template endpoint, which is unusual enough to be worth naming. Several operations have a companion endpoint that returns the options for performing them — the valid account types for a transfer, the fields a form should offer. It is the server telling you what the user is permitted to choose, which is genuinely useful and means a form cannot be built statically from the documentation.

Why the obvious fix failed
Use the full API and authenticate as an administrator. Removes every restriction and is the wrong answer for a customer-facing app, because it puts credentials capable of arbitrary operations into a mobile client. The restricted namespace exists to make that impossible, and routing around it reintroduces exactly the risk it was designed to remove.
Treat missing endpoints as bugs and file them. Sometimes correct and it is a slow path for something you need this sprint. More to the point, several omissions are deliberate, so a blanket assumption that absence is oversight produces a stream of issues that are answered with a reason rather than a fix.
Build a thin pass-through service with elevated access. The pragmatic workaround, and it does not stay thin. Once a service exists that can do what the client cannot, every subsequent gap is solved by adding an operation to it, and after six months there is a second backend with its own auth model and nobody can describe the boundary.
Wrap it in a generic client and deal with the gaps later. What I did first. A generated client from the full specification compiles cheerfully and gives you methods that return 403 or 404 at runtime — the type system reporting an API surface the server does not actually offer.
That is a particular kind of unhelpful, because the compiler is doing exactly what it was asked and the result is worse than having no types at all. A method that cannot be called is a false assurance sitting in autocomplete, and it will be found by whoever writes the feature that needs it, at the point where they have already designed around its existence.
The actual mechanism
Treat the self-service namespace as its own API with its own shape, and build the client to that rather than to the platform's full specification.
namespace /fineract-provider/api/v1/self/ — a surface, not a filter
auth Basic credentials plus a tenant header on every request
identity authenticate as a user, then resolve the client to operate on
templates ask the server what the user may choose, then render the form
The third line is the one that shapes the architecture. Because identity resolution is a call rather than a claim, the client has a startup sequence before any feature works, and the identifier it produces is a parameter on nearly everything downstream. Designing that as an explicit piece of application state — rather than letting each feature fetch it again — is the difference between one round trip and one per screen.
It is also the piece most likely to be got wrong twice. The first version fetches it per feature, which is obviously wasteful and obviously fixable; the second caches it forever, which is fine until the association changes and the app is operating confidently on behalf of the wrong record. The lifetime of that identifier is a real decision and it is usually made by whoever adds the cache.
The sub-case that is easy to under-build is the template-driven form. It is tempting to hardcode the options, because they are in the documentation and they rarely change. They are per-user and per-institution, though, and the endpoint exists precisely because the platform cannot know in advance which ones apply — so a hardcoded form is correct in development and wrong for some deployment you will not see.
The same reasoning applies to anything the server offers as options rather than as documentation. If an endpoint exists whose only job is to say what is permitted, that endpoint is the specification and the written documentation is a description of a typical case. Treating it the other way round produces a client that works against the instance it was built on.
There is a companion-service pattern that is genuinely right rather than a workaround, and the distinction matters. A service that performs operations the client is forbidden from performing is a hole in the security model. A service that composes several permitted calls, or caches, or does work the device should not — that is ordinary backend-for-frontend, and it is the correct home for anything requiring more than one call to answer one question.
The fix
model the self-service surface as the API, not as a subset
resolve identity once at startup, hold it as application state
template drive forms from the server's options endpoints
compose a companion service may combine permitted calls, never exceed them
The second line is the one that removes the most incidental code, and it is worth seeing because the alternative is so tempting. Identity resolves once and is held; nothing downstream passes an id around or re-derives one:
// SelfServiceSession.kt — resolved ONCE, at authentication.
data class SelfServiceSession(
val clientId: Long,
val authToken: String,
val permitted: Set<String>, // from the server, not assumed
)
class SelfServiceSessionStore(private val api: SelfServiceApi) {
private val _session = MutableStateFlow<SelfServiceSession?>(null)
val session: StateFlow<SelfServiceSession?> = _session.asStateFlow()
suspend fun authenticate(username: String, password: String) {
val auth = api.authenticate(username, password)
// The client id comes from the server's own view of who this is. Deriving it
// from anything the app knows is how an app ends up requesting another
// customer's data and getting a 403 it cannot explain.
_session.value = SelfServiceSession(
clientId = auth.clients.single(),
authToken = auth.base64EncodedAuthenticationKey,
permitted = auth.permissions.toSet(),
)
}
fun require(): SelfServiceSession =
_session.value ?: error("no session — call authenticate() first")
}
auth.clients.single() is deliberate rather than defensive. The self-service surface assumes one
client per login, and a login returning two is a situation this app has no correct behaviour for —
failing loudly at the boundary beats picking the first one and being subtly wrong for the rest of
the session.
What the server actually returns, which is what makes the permitted set worth holding:
POST /self/authentication
{
"clients": [ 1 ],
"base64EncodedAuthenticationKey": "…",
"permissions": [ "READ_CLIENT", "READ_LOAN", "CREATE_SELFSERVICE_TRANSFER" ]
}
No UPDATE_CLIENT. The surface is read-mostly and the permission list says so at login — which is
better than discovering it from a 403 on a screen the user has already filled in.
The fourth line is the test I apply when the pressure to add a service is highest. The question is not whether a companion service is allowed — it usually is — but whether it needs credentials the client could not have. If it does, that is not a convenience layer, it is a privilege escalation with a nice interface, and the thing to do is to find out why the operation is restricted before building a way around it.
What makes this tractable is that the restricted surface is small enough to hold in your head. A few dozen endpoints across a handful of categories is a day's reading, and reading them as a set — rather than looking each one up when a feature needs it — is what surfaces the identity sequence and the template pattern before they surprise you.
That reading is also the cheapest time to notice what is not there. An absence is almost invisible when you are looking up one endpoint at a time, because you find what you came for and move on; laid out as a set beside the full platform surface, the shape of what was withheld is the most informative thing on the page.
How we proved it
The namespace is enumerable and that is the whole first step. Filtering the platform's API surface down to the self-service endpoints produces a list of a few dozen across about a dozen categories, and having that list as a document rather than a search is what makes the gaps visible as gaps rather than as individual surprises during development.
The identity sequence is directly observable. Authenticate, then call the client list, and the response is a page rather than a single record — which tells you immediately that the association is one-to-many and that the client must choose or default. That is a thirty-second experiment and it settles an architectural question.

What I cannot tell you is how representative my deployment is. A platform like this is configured per institution, and which operations are available, which fields are required and which options appear in a template all vary with that configuration. My experience is one configuration, and the part I am confident generalises is the shape of the problem rather than the specifics.
What I have not solved: the deliberate-versus-incidental question. I still cannot reliably tell whether a missing operation is a considered restriction or an unfilled gap, and the two call for completely different responses. The only method I have is to ask upstream, which is the right thing to do and does not fit inside a sprint.
And a limitation of the restricted-surface design itself, stated fairly: it optimises for safety at the cost of making some legitimate customer-facing features awkward or impossible without additional services. That is a defensible trade for a platform holding other people's money, and it does mean that "the API does not support it" is sometimes the end of a product conversation rather than the start of an engineering one.
The thing I would tell somebody starting is to read the restricted surface as a design document. What a self-service API chooses not to expose is a fairly direct statement about what the platform's authors think a customer should be able to do unsupervised, and most of my early friction came from arguing with that statement rather than from reading it.
What this generalizes to
A restricted API is a smaller API, not a filtered one, and the difference shows up in its shape rather than in its contents.
The pattern appears wherever a system exposes a reduced surface to a less-trusted caller. A public subset of an internal service. A read-only replica with a different consistency model. A partner API alongside a first-party one. A sandbox environment. In every case the temptation is to treat the reduced surface as the full one minus some entries, and in every case it has its own authentication shape, its own identity model and its own idea of what a single operation is.
So the useful first question about any restricted interface is what it assumes about who is calling — because that assumption is what produced the omissions, and once you can state it, the absences stop looking arbitrary and start telling you what the thing was designed to prevent.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".