NFC Wearable Payments App
A companion app for a contactless payment ring, wrapping a licensed provisioning SDK in a custom React Native native module on both platforms.
- Period
- Jul 2025 – Feb 2026
- Role
- Client work
- Status
- shipped
- commits
- 146
- hand-written native LOC
- ~2,160
- authored
- 100% to handover
Freelance client work I found and contracted myself, under a signed NDA. Sole developer, and the only developer on it until handover. Described generically for that reason.
Highlights
- Provisioning is modelled as events rather than promises, because it is a multi-round conversation (tap, server round-trip, user input, tap again) and a single promise cannot express step three of seven.
- The NFC hardware has one callback slot and separate manager classes were each claiming it, so whichever claimed it last silently won. Collapsing them into one owner of the session makes that race impossible rather than guarded against.
- A four-state delivery machine decides when recovery is appropriate: a dropped session restarts itself while data is moving to the device, and deliberately does not while the flow is waiting on the user.
- The platform boundary is a TypeScript discriminated union rather than a class hierarchy, so exactly one platform branch survives in the whole interaction path, and only because app store links use different URI schemes.
- Once the device identifier is cached some operations need no tap, so a parallel entry point emits a flag that tells the UI to show a spinner instead of a tap prompt, and NFC starts lazily.
No client, product or SDK named, and no code, repository or screenshots. Architecture and platform technique only.
Delivered as a release candidate that passed the payment partner's review. Store submission sat with the client.
No tests and no CI, on a codebase whose difficulty is entirely state machines and failure paths.
What I can and cannot say
Paid client work I sourced and contracted myself, delivered alone, and signed off by a third party. I was the only developer on it for seven months, took it from the first commit to handover, and the app passed a payment partner's UI and compliance review.
It was freelance work under a signed NDA, from July 2025 to handover in February 2026, and I wrote all 146 commits on the main branch. The product is a companion app for a contactless payment wearable, and the app's job is to provision payment cards onto that hardware over NFC by driving a licensed provisioning SDK.
There is no repository link on this page, no code and no screenshots, and I do not name the client, the product or the SDK vendor. What follows is architecture and technique only.
The problem
Provisioning a card is not one operation. It is a conversation: the user taps the wearable, the app talks to a server, the server asks for something only the user can supply, the user fills in a form, and then the wearable has to be tapped again so the data can actually be written to it. Any step can take an indeterminate amount of time, the app may be backgrounded in the middle, and the hardware can drop out at any point.
On top of that it had to work identically on both platforms, on top of a vendor SDK with a different shape on each, wrapped in a custom React Native native module I wrote from scratch. The two native sides came to about 1,100 lines of Swift and 1,050 of Kotlin, behind one bridge surface of eight functions and ten events that is identical on both.
Events, not promises
The first real decision was refusing to model provisioning as a promise. A promise expresses "start this, tell me when it is done", and this operation has seven steps with the user standing in the middle of three of them. There is no way to express step three of seven in a value that only resolves at the end.
So the native side emits events, and the JavaScript side is a state machine driven by them. When the flow reaches a point where it needs the user, the native module stores the continuation as a suspended closure, stops NFC, and emits an event describing what it needs. The app renders whatever that describes, collects the answer, and calls back in with it, which resumes the stored closure exactly where it left off.
The thing this buys is that the pause is genuinely open-ended. The user can navigate, the app can re-render, and on the auth path the user can leave the app entirely and come back. None of that touches the native flow, which is sitting on a closure waiting to be called. A promise-based design would have needed the whole interaction to live inside one async function on the JS side, which cannot survive the app being backgrounded.
One owner of the NFC session
The hardware is exclusive. There is one NFC session and one callback slot for it, and the original iOS code had two separate manager classes each assigning themselves to that slot, one for reading device information and one for running services. Whichever had assigned itself most recently silently won the next callback, so the other one's in-flight scan simply never got its answer. It is not a crash and there is no compiler error; it presents as the device connecting and nothing happening, and only under particular call orderings.
In January 2026 I collapsed the two into a single manager that owns the session for the lifetime of the module and dispatches internally on its own state. There is now exactly one object that can claim the callback slot, so the race is not guarded against, it is structurally impossible. Guarding it instead, by having each manager check whether the other was busy, was the alternative and would have left the shared slot in place.
That state is a four-state machine: not started, then a state where the service description has been fetched and the flow may need user input, then a waiting-on-user state, then a transceiving state while data is actually moving to the hardware. The states are what make recovery selective. If the NFC session drops while transceiving, it restarts automatically, because the user is mid-tap and expects it to keep working. If it drops while waiting on user input it does not, because there is nothing to talk to yet and re-arming the reader would prompt the user to tap for no reason.
Two and a half weeks later I ported that design the other way. The other platform had three classes with the same problem in a different form, each constructing its own discovery manager rather than sharing one, and they were replaced with the same single-owner state machine. The two platforms ended up with the same states, the same suspended closure pattern and the same function and event names.
One platform branch in the whole interaction path
Each platform's SDK models a user requirement differently. Rather than mirroring that with a class hierarchy and a pair of platform implementations, the boundary is a TypeScript discriminated union: a request is an input, or an app to open, or a URL to open, or a web activation, or something to display, or unknown. Each native side flattens its own SDK's model into that union before it crosses the bridge.
The result is that the entire interaction path contains exactly one place that asks which platform it is running on, and it is there because app store deep links use different URI schemes and nothing can be done about that. Every other difference between the two SDKs was absorbed at the boundary rather than propagated through the UI.
Some operations need no tap at all
Once a wearable has been provisioned, its identifier is known and stored. Operations like suspending or removing a card do not need to touch the hardware to begin, because the server can be asked about a device by identifier. So there is a parallel entry point that starts from the cached identifier, and the first progress event it emits carries a flag saying no device connection is required. The UI reads that flag and shows a spinner instead of the tap prompt and the animation telling the user to hold their wearable to the phone.
NFC then starts lazily, only if and when the flow reaches a point where data genuinely has to be written to the device. The naive version asks for a tap up front on every operation because some operations need one, which trains users to tap for things that did not require it.
Leaving the app and coming back
Some card issuers authenticate the user in their own app or on their own website. That means handing control to something else entirely and hoping to get it back. The native handler suspends across the excursion, keyed to the specific requirement that asked for it, and resumes when the app is reopened through its custom URL scheme with the response.
Most of the work here is in the ways it goes wrong. If the issuer's app is not installed, the flow falls back to the app store listing rather than failing. If the callback URL cannot be parsed, the handler resumes with an empty response rather than leaving the flow suspended forever. And if the user completes authentication in a browser and returns to the app manually without the site ever firing the redirect, an app-state listener notices the app came back to the foreground with an interaction still pending and completes it. Each of those is a state the flow could otherwise get permanently stuck in.
The compliance review
In February 2026 the app went through a review by the payment partner before it could go live. Four things came out of it. A consent screen was added before a card is linked, disclosing who is providing the payment service and what linking the card does, shown specifically on the install path. The payment card display was enlarged across every screen. Unfinished account and registration screens were removed rather than shown half-built. And a hardware authenticity check was added.
On that last one, each wearable reports a batch identifier and the app checks it against an allow-list held in a remote document rather than compiled into the app. A device that is not on the list is rejected to a dedicated screen. Since the list is remote, approving a new hardware batch is a document edit rather than an app store release.
The app was delivered as a release candidate that passed that review. Store submission sat with the client.
Known gaps
There are no tests and no CI, on a codebase whose entire difficulty is state machines and failure paths. The state machine that recovers a dropped NFC session, the fallbacks around the auth excursion and the scanless path are all pure logic with clear inputs and outputs, and all of them were verified by tapping a physical device against a phone.
Third-party keys are committed in plaintext in the repository. It is private and is not linked from this site.