Skip to main content
The thesis made the argument; this page is the implementation. A CustomerIdentity is a person, keyed by phone number, living in the core schema — deliberately not scoped to any tenant. It owns saved payment methods that work everywhere.

Why phone, why WhatsApp

Email is a weak identity in Nigeria — barely checked, easily mistyped. The phone number is the real primary key for a person, and WhatsApp is the channel everyone has and trusts. So Duro verifies ownership of the phone with a one-time code over the WhatsApp Cloud API, and the verified phone becomes the wallet’s key.

The OTP flow

Security properties, all real:
  • The code is never stored in clear — only sha256(code). Verification is a timing-safe hash compare (Hash.verifyHashedSecret).
  • Brute force is bounded — 6 digits × 5 attempts per challenge × 5 challenges per 15 minutes is negligible search space, and rate-limited besides.
  • Codes expire in 10 minutes and are single-use (consumedAt).
  • The identity token is marked keyId: identity so it can’t masquerade as a tenant credential — present it to /v1 and it resolves to an identity id, not a tenant, and fails. See request lifecycle →

The cross-merchant wallet

A SavedPaymentMethod hangs off the identity, not a tenant. The presenter exposes only rail, brand, and last4 — the gateway token is never returned over the API. When the customer pays at any merchant, Duro links a tenant-scoped Customer to the same identity by phone, so the merchant sees their customer while the wallet stays shared.

Re-login returns the same person

Because the key is the phone, a second OTP for the same number resolves to the same identity — verified live in the build. That’s what makes “one portal, every merchant” possible: there’s exactly one identity per phone, ever. Next: the customer portal built on top of this.