Payhere · P2P Payments

MSN-006 · 2023 · react, aws, nlp, dynamodb, lambda, geohash

P2P payment PWA built at SUTD What The Hack. Voice-activated payee selection using NLP, geospatial queries in DynamoDB, AWS Lambda and Stripe on the backend.

Built in 24 hours during SUTD's "What The Hack" 2023 hackathon, Payhere was my project exploring how to eliminate the awkward friction of peer-to-peer payments in physical social environments.

In Singapore, settling a group dinner bill over PayNow or FAST usually turns into a chore: shouting 8-digit mobile numbers across a noisy table, typing national IDs, or passing phones around to scan static QR codes one by one. I wanted a zero-touch payment primitive: you speak a natural sentence—*"Send $15 to John for dinner"*—and your phone automatically detects nearby friends via micro-geofencing, extracts the dollar amount and payee on-device, and stages the transaction for one-tap biometric execution.

Built and deployed from scratch within 24 hours at SUTD What The Hack 2023. Demonstrated zero-touch peer discovery and payment intent staging across multiple mobile devices simultaneously, running entirely on a serverless AWS backend.

---

1. System Architecture: Serverless Edge & Cloud Topology

I architected the system as a lightweight Progressive Web App (PWA) communicating with an AWS serverless backend. To minimize payment friction, I offloaded speech processing directly to the mobile device while using AWS Lambda and Amazon DynamoDB for stateless geospatial clustering.

┌────────────────────────────────────────────────────────────────────────────────────────┐
│ MOBILE CLIENT (React PWA • Mobile Chrome / Safari)                                     │
│                                                                                        │
│   ┌───────────────────────────┐   ┌──────────────────────┐   ┌─────────────────────┐   │
│   │ Web Speech API (STT)      │──►│ Edge NLP Rule Parser │──►│ Geolocation API     │   │
│   │ Audio Stream -> String    │   │ Extracts {amt, user} │   │ Lat/Lon Coordinate  │   │
│   └───────────────────────────┘   └──────────────────────┘   └──────────┬──────────┘   │
└─────────────────────────────────────────────────────────────────────────┼──────────────┘
                                                                          │ HTTPS REST
                                                                          ▼
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ AWS CLOUD INFRASTRUCTURE (ap-southeast-1)                                              │
│                                                                                        │
│   ┌────────────────────────────────────────────────────────────────────────────────┐   │
│   │ Amazon API Gateway (HTTP API • TLS 1.3 Termination • Cors Enabled)             │   │
│   └───────────────────────────────────────┬────────────────────────────────────────┘   │
│                                           │ Event Bridge / Direct Lambda Proxy         │
│                                           ▼                                            │
│   ┌────────────────────────────────────────────────────────────────────────────────┐   │
│   │ AWS Lambda (Python 3.11 Runtime • Lightweight Math/Geohash Handlers)          │   │
│   │ • POST /presence/heartbeat   • GET /peers/nearby   • POST /payment/stage       │   │
│   └───────────────────┬───────────────────────────────────────┬────────────────────┘   │
│                       │ Query 9-Cell Geohash                  │ Create PaymentIntent   │
│                       ▼                                       ▼                        │
│   ┌───────────────────────────────────────────────┐   ┌────────────────────────────┐   │
│   │ Amazon DynamoDB                               │   │ Stripe Payments API        │   │
│   │ • Partition: GEO#<geohash_6>                  │   │ • Ephemeral Keys           │   │
│   │ • Sort Key:  <timestamp>#<user_id>            │   │ • Webhook Verification     │   │
│   └───────────────────────────────────────────────┘   └────────────────────────────┘   │
└────────────────────────────────────────────────────────────────────────────────────────┘

Component Breakdown

1. Frontend PWA: Built with React 18, Vite, and TailwindCSS. Registered a service worker for offline asset caching and rapid home-screen installation on iOS and Android without an app store wrapper. 2. Edge Speech & NLP Layer: Employs the browser's native `webkitSpeechRecognition` engine combined with a fast client-side slot-filling parser, ensuring tokenization and entity extraction happen in <15 ms<15\text{ ms} on-device. 3. API Gateway & AWS Lambda: Stateless Python 3.11 microservices serving REST endpoints for ephemeral user presence registration, spatial bounding box queries, and Stripe transaction initialization. 4. DynamoDB Geospatial Store: A single-table schema with a Global Secondary Index (GSI) optimized for spatial-temporal range queries using Base32 Geohash strings. 5. Stripe API Integration: Secure payment intent orchestration, handling balance reservations and mock settlement flows without storing sensitive cardholder data.

---

2. Natural Language Payee Selection & Edge Intent Parsing

Early in the hackathon, we considered sending transcribed voice audio to a server-side LLM endpoint (e.g. GPT-3.5 or an AWS Lex bot). However, network latency tests on crowded venue Wi-Fi revealed a 1.8 to 2.4-second round-trip latency for remote NLP—far too slow for what needed to feel like an instantaneous payment interaction.

We shifted intent parsing entirely to the client edge. Once the browser's Web Speech API returns an interim or final transcription string, a deterministic finite-state tokenizer extracts the core transaction slots:

"Send   $15.50   to   John   for   dinner"
  │       │       │    │      │       │
[VERB] [AMOUNT]  [P] [PAYEE] [P]   [MEMO]

Tokenizer Implementation

# Conceptual Slot-Filling Schema running in TypeScript on the client
interface ParsedIntent {
  intent: 'TRANSFER' | 'SPLIT' | 'UNKNOWN';
  amount: number | null;
  currency: string;
  payeeName: string | null;
  memo: string;
}

const INTENT_REGEX = /^(?:send|pay|transfer)\s+\$?([0-9]+(?:\.[0-9]{1,2})?)\s+to\s+([a-zA-Z]+)(?:\s+for\s+(.+))?$/i;

Phonetic & Fuzzy Payee Resolution

Voice transcriptions in noisy dining environments frequently garble names (e.g., "Jon" instead of "John", "Sara" instead of "Sarah").

Rather than failing on exact string mismatches, the client combines Levenshtein Distance with Double Metaphone phonetic encoding against the candidate list of nearby users returned by the geospatial query:

Similarity Score=(1Levenshtein(Stranscribed,Speer)max(Stranscribed,Speer))×0.6+PhoneticMatch×0.4\text{Similarity Score} = \left(1 - \frac{\text{Levenshtein}(S_{\text{transcribed}}, S_{\text{peer}})}{\max(|S_{\text{transcribed}}|, |S_{\text{peer}}|)}\right) \times 0.6 + \text{PhoneticMatch} \times 0.4

If the highest-scoring candidate exceeds a confidence threshold of 0.800.80, the UI pre-selects that user immediately and highlights their avatar on screen.

---

3. Geospatial Proximity Queries in DynamoDB

The core backend challenge was peer discovery: when a user opens the app, how does the system find other active users sitting within 5 to 20 meters without executing an expensive full-table scan?

DynamoDB is a key-value and document store; it does not support native multi-dimensional geospatial indexing (unlike PostgreSQL/PostGIS with R-tree indexes). A naive query that scans all active users and evaluates the Haversine distance in Lambda would consume excessive Read Capacity Units (RCUs) and degrade rapidly as user count scaled:

Haversine Distance: d=2Rarcsin(sin2(Δϕ2)+cosϕ1cosϕ2sin2(Δλ2))\text{Haversine Distance: } d = 2R \arcsin \left(\sqrt{\sin^2\left(\frac{\Delta \phi}{2}\right) + \cos \phi_1 \cos \phi_2 \sin^2\left(\frac{\Delta \lambda}{2}\right)}\right)

The Geohash Spatial Indexing Solution

To map 2D latitude and longitude into DynamoDB's 1D hash partition keys, we used Geohash encoding. A Geohash recursively subdivides the globe into hierarchical bounding boxes, interleaving latitude and longitude bits into a base32 representation.

========================================================================================
GEOHASH PRECISION HIERARCHY & BOUNDING BOX RESOLUTION
========================================================================================
Precision 4:  ±20 km   x  ±20 km       [Regional / Metropolitan]
Precision 5:  ±2.4 km  x  ±4.9 km      [District level]
Precision 6:  ±610 m   x  ±1.2 km      <-- OUR CHOSEN PARTITION CELL (GSI1PK)
Precision 7:  ±76 m    x  ±153 m       [Micro-neighborhood]
Precision 8:  ±19 m    x  ±19 m        [Building / Venue footprint]
========================================================================================
Using a fixed 6-character Geohash prefix (e.g. `w21z7x`) divides Singapore into grid cells of roughly 1.2 km×0.6 km1.2\text{ km} \times 0.6\text{ km}. This provides the optimal balance: coarse enough to cluster nearby users into identical partition buckets, but fine enough to avoid excessive post-query in-memory filtering.

Overcoming the Cell Boundary Edge Case

If User A sits at `1.340001, 103.963001` and User B sits at `1.339999, 103.962999`, they may be separated by only 2 meters across a coordinate boundary—placing them into entirely different Geohash strings.

To eliminate this boundary edge case, the backend implements the 9-Cell Bounding Box Algorithm: 1. Compute the user’s primary 6-character Geohash cell. 2. Calculate the 8 immediate geographic neighbor cells (North, North-East, East, South-East, South, South-West, West, North-West). 3. Execute parallel DynamoDB `Query` operations across all 9 partition keys.

┌─────────────┬─────────────┬─────────────┐
│  Neighbor   │  Neighbor   │  Neighbor   │
│  (NW Cell)  │   (North)   │  (NE Cell)  │
├─────────────┼─────────────┼─────────────┤
│  Neighbor   │    USER     │  Neighbor   │
│   (West)    │ (Center 6)  │   (East)    │
├─────────────┼─────────────┼─────────────┤
│  Neighbor   │  Neighbor   │  Neighbor   │
│  (SW Cell)  │   (South)   │  (SE Cell)  │
└─────────────┴─────────────┴─────────────┘
  ◄────── 9 Parallel Bounding-Box DynamoDB Queries ──────►

DynamoDB Table Schema

{
  "PK": "USER#u_8f91a2c4",
  "SK": "METADATA",
  "GSI1PK": "GEO#w21z7x",
  "GSI1SK": "1687023842#u_8f91a2c4",
  "name": "John Doe",
  "lat": 1.34125,
  "lon": 103.96342,
  "ttl": 1687024142
}

- `GSI1PK` (`GEO#`): Groups all nearby users within the same 1 km\sim 1\text{ km} spatial bucket. - `GSI1SK` (`#`): Enables range filtering on the sort key (`GSI1SK > now - 120`), ensuring the query only reads active users who broadcasted a heartbeat within the last 2 minutes. - `ttl` (DynamoDB Time-To-Live): DynamoDB automatically purges expired presence records after 5 minutes, preventing dead sessions from accumulating in the table.

Once the candidate set (typically 5 to 15 users in the 9 cells) is returned from DynamoDB, the Lambda handler runs the exact Haversine distance formula in Python, pruning any candidates beyond 25 meters25\text{ meters}.

---

4. What Broke & Systems Trade-offs

Building a distributed real-time mobile app in 24 hours under hackathon venue constraints surfaced three distinct engineering failures:

1. AWS Lambda Cold-Start Latency Spike

During early testing, the first payment intent after an idle period took 1,120 ms1,120\text{ ms} to resolve.

Root Cause

Our initial Lambda deployment packaged `geopy` and `scipy` for coordinate calculations. Extracting these heavy scientific Python packages into the Lambda execution environment added over 800 ms800\text{ ms} of import initialization overhead during Firecracker microVM container cold starts.

The Fix

We stripped out all third-party spatial libraries. The Haversine distance, Geohash base32 encoder, and 8-neighbor bounding box calculations were rewritten in pure Python standard library (using standard `math.sin`, `math.cos`, `math.radians`). This compressed the Lambda deployment artifact from 48.2 MB48.2\text{ MB} down to 12.4 KB12.4\text{ KB}, dropping cold start latency from 1,120 ms1,120\text{ ms} down to 185 ms185\text{ ms}.

2. DynamoDB Partition Hotspotting & WCU Throttling

During the live demonstration, when 30+ attendees and judges in the same auditorium opened the web app simultaneously, the backend began throwing HTTP 500 errors.

botocore.exceptions.ClientError: An error occurred (ProvisionedThroughputExceededException) 
when calling the PutItem operation: The level of configured provisioned throughput for the 
table was exceeded.

Root Cause

Because all 30 devices were located within the same 100-meter auditorium, every single client heartbeat was writing to the exact same partition key (`GSI1PK = GEO#w21z7x`). Our provisioned capacity was set to the default free-tier limit of 5 Write Capacity Units (WCUs). Thirty devices writing every 3 seconds required 10 WCU\ge 10\text{ WCU} sustained on a single partition key, triggering aggressive AWS throttling.

The Fix

1. Switched DynamoDB from Provisioned Mode to On-Demand (Pay-Per-Request) mode, removing fixed partition write ceilings. 2. Added client-side polling jitter: instead of writing every 3.0 seconds on the dot, clients randomized their heartbeat intervals: Tpoll=5.0 s+U(1.5 s,+1.5 s)T_{\text{poll}} = 5.0\text{ s} + \mathcal{U}(-1.5\text{ s}, +1.5\text{ s}) This smoothed out write bursts and prevented synchronized lockstep requests.
Desktop Chrome allows immediate continuous audio recognition. iOS Safari, however, enforces strict user-gesture policies: calling `webkitSpeechRecognition.start()` outside a synchronous `touchend` or `click` event throws an uncatchable `NotAllowedError`. Safari also abruptly terminates speech recognition sessions after 3 seconds of silence without firing an `onend` event, requiring a client-side watchdog timer to re-initialize the audio context.

---

5. Stripe API Integration & Settlement Pipeline

To maintain PCI compliance and avoid handling raw credentials, the React frontend never touches payment credentials directly:

[Client PWA] ──(1) Stage Payment Intent──► [AWS Lambda]
                                                  │
                                                  ▼ (2) Create Intent
                                          [Stripe API Engine]
                                                  │
[Client PWA] ◄──(3) Client Secret + ID ───────────┘
      │
      ▼ (4) Confirm Payment with Biometrics (TouchID / FaceID)
[Stripe SDK] ──(5) Cryptographic Settlement──► [Bank / Card Network]

1. Ephemeral Staging: Once the payee is confirmed via voice match, Lambda calls Stripe's `/v1/payment_intents` endpoint with `{ amount, currency: 'sgd', capture_method: 'manual' }`. 2. One-Tap Execution: Lambda returns the Stripe `client_secret` to the PWA. The app displays an LCARS-style confirmation modal with the detected recipient's name, avatar, and amount. 3. Biometric Authorization: The user taps "Confirm", invoking the Web Authentication API / Apple Pay sheet for final settlement.

---

6. Engineering Takeaways

- Geohashes turn 2D spatial queries into linear range queries: Without specialized GIS databases, geohashing provides a clean, highly scalable mechanism to run proximity queries in pure key-value engines like DynamoDB. - Edge computing is mandatory for natural voice UX: Offloading speech recognition and intent classification to the mobile browser eliminated hundreds of milliseconds of cloud round-trip time, making the interaction feel tactile and instant. - Serverless functions require extreme dependency hygiene: Third-party libraries that seem harmless in long-running containerized servers can completely destroy the user experience of a serverless microservice through cold-start penalties.

---

Stack

- Frontend: React 18, Vite, TypeScript, TailwindCSS, Web Speech API (`webkitSpeechRecognition`), Geolocation API - Cloud Backend: AWS Lambda (Python 3.11, zero third-party dependencies), AWS API Gateway (HTTP APIs) - Database: Amazon DynamoDB (Single-table design, GSI geohash indexing, TTL automated expiry) - Payments: Stripe API (PaymentIntents, Webhooks) - Hosting: AWS S3 + Amazon CloudFront CDN