The white-label Buy Now Pay Later platform.
Ship a branded BNPL business in a weekend. Five components — admin panel, customer app, merchant app, embeddable widget, REST API — drop in your region's providers, edit one config file to rebrand, and you're live.
Overview
FlexBuy ships as a complete, source-available BNPL stack. The five surfaces:
Admin panel
Laravel 13 + Filament 4. Underwriting, portfolio, merchants, settlements, collections, analytics.
Customer app
Flutter 3 (iOS + Android). 23 screens. KYC, credit, loans, payments, virtual cards, referrals.
Merchant app
Flutter 3 (iOS + Android). 16 screens. Sales, settlements, refunds, plans, webhooks.
Checkout widget
3.6kb vanilla-JS bundle. One snippet, iframe modal. "Pay with FlexBuy".
REST API
Sanctum auth. 86 endpoints. HMAC-signed webhooks. 72-hour retry tail.
Credit engine
Rule-based scoring (0–1000). Tier A–E. Pluggable bureau adapters. Override-able.
Install in 10 minutes
From the repo root:
./install.sh
cd backend && php artisan serve
# Admin: http://localhost:8000/admin · [email protected] / password
The installer composer-installs, npm-builds the widget, runs migrations,
seeds 200 demo loans, and (if Flutter is on PATH) pub-gets both apps. From
there it's flutter run in either app dir to launch.
Requirements
- PHP 8.3+
- Composer 2
- Node 18+
- PostgreSQL 14+ (SQLite for dev)
- Redis (recommended for queues + cache; optional first run)
- Flutter 3.x (optional — only for the mobile apps)
Demo credentials
All seeded accounts share the password password.
Admin panel
| Role | |
|---|---|
[email protected] | Super |
[email protected] | Underwriter |
[email protected] | Collections |
[email protected] | Finance |
[email protected] | Support |
Customer app
50 demo customers, [email protected] through [email protected].
Pick a verified one with loans:
| KYC | Loans | |
|---|---|---|
[email protected] | Verified | 5 |
[email protected] | Verified | 5 |
[email protected] | Verified | 7 |
Merchant app
One owner per seeded merchant. Login is 2-step: email/password → OTP.
| Merchant | |
|---|---|
[email protected] | Lumatrx Electronics (NG) |
[email protected] | Skyline Travel (KE) |
[email protected] | Northshore Fashion (US) |
Architecture
The backend is the source of truth. All four clients (customer app, merchant app, widget, admin) talk to it over HTTPS.
┌───────────────┐ ┌────────────────┐ ┌────────────────┐
│ Customer App │ │ Merchant App │ │ JS Widget │
└──────┬────────┘ └─────────┬──────┘ └─────────┬──────┘
└──────────────────────┴────────────────────┘
▼
┌───────────────────────┐
│ REST API (Laravel) │
└───────────┬───────────┘
┌──────────────────────┼──────────────────────┐
┌──────▼──────┐ ┌────────────▼────────────┐ ┌──────▼──────┐
│ Filament │ │ CreditEngine · Loan- │ │ Collections │
│ Admin │ │ Service · Settlement- │ │ Service │
└─────────────┘ │ Service │ └─────────────┘
└─────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Lumatrx: Zauthy · PostaLynk · Suppoora · │
│ Zencoinpay │
└─────────────────────────────────────────────────┘
Brand config
The whole stack is rebrandable from one file per surface. No code changes required.
Backend — backend/config/brand.php
Single source of truth. Every value is env-driven so you can override per environment without editing the file.
return [
'name' => env('BRAND_NAME', 'FlexBuy'),
'tagline' => env('BRAND_TAGLINE', 'Pay smarter. Live easier.'),
'colors' => [
'primary' => env('BRAND_COLOR_PRIMARY', '#0A0F1E'),
'accent' => env('BRAND_COLOR_ACCENT', '#E0FF6B'),
// ... 11 more
],
'features' => [
'biometric_login' => env('BRAND_FEATURE_BIOMETRIC_LOGIN', true),
'referrals' => env('BRAND_FEATURE_REFERRALS', true),
'crypto_repayment' => env('BRAND_FEATURE_CRYPTO_REPAYMENT', false),
// ...
],
// ...
];
The admin panel reads brand.name and brand.colors.primary directly.
The customer + merchant apps fetch GET /api/v1/brand at boot and cache
locally. The landing page reads landing/brand.config.js.
One-file rebrand
To rebrand FlexBuy to "Paylo" with violet + amber:
# 1. Backend .env
BRAND_NAME=Paylo
BRAND_TAGLINE=Pay later, everywhere.
BRAND_COLOR_PRIMARY=#6D28D9
BRAND_COLOR_ACCENT=#FBBF24
BRAND_LOGO_URL=https://your-cdn/paylo-logo.svg
# 2. Restart the backend and re-fetch the apps' brand cache:
php artisan config:clear && php artisan serve
# 3. landing/brand.config.js
window.FLEXBUY_BRAND = {
name: 'Paylo',
colors: { primary: '#6D28D9', accent: '#FBBF24' },
};
That's it. Filament reskins on next reload, the apps re-theme on next launch, the landing page swaps colors + copy at load.
Colors & fonts
Colors
13 color slots — set via env or directly in the config file. The customer
app's theme exposes all of them via FlexBuyTheme.fromBrand(brand):
| Token | Env | Default |
|---|---|---|
| primary | BRAND_COLOR_PRIMARY | #0A0F1E |
| accent | BRAND_COLOR_ACCENT | #E0FF6B |
| success | BRAND_COLOR_SUCCESS | #12A37C |
| warning | BRAND_COLOR_WARNING | #E6A23C |
| danger | BRAND_COLOR_DANGER | #E0464E |
| surface | BRAND_COLOR_SURFACE | #FAFBFC |
| text_primary | BRAND_COLOR_TEXT_PRIMARY | #0A0F1E |
| text_muted | BRAND_COLOR_TEXT_MUTED | #6B7180 |
Fonts
Set BRAND_FONT_FAMILY=Inter (default) or any Google Fonts family. Both
apps load it via google_fonts:
BRAND_FONT_FAMILY=Manrope
Copy strings
Every brand-significant string in the customer app is keyed in
brand.copy. Override via env:
BRAND_COPY_ONBOARDING_TITLE_1="Buy now, sleep easy"
BRAND_COPY_HOME_HERO_LABEL="Spendable balance"
BRAND_COPY_SUPPORT_CHAT_LABEL="Talk to a human"
Same on the landing page — tag any element with
data-brand-copy="key" and it picks up the value from
window.FLEXBUY_BRAND.copy[key] at load.
Feature flags
Switch surfaces on/off without touching code:
| Flag | Default | Effect |
|---|---|---|
| biometric_login | true | Hide Face ID / fingerprint on login |
| referrals | true | Hide the Referrals screen + endpoints |
| virtual_cards | true | Hide the Cards tab |
| crypto_repayment | false | Show Zencoinpay payment method |
| merchant_widget | true | Allow widget sessions |
| kyc_required | true | Block loan authorisation until tier 2+ |
Logo & assets
Provide URLs in your .env:
BRAND_LOGO_URL=https://your-cdn.com/logo.svg
BRAND_ICON_URL=https://your-cdn.com/icon-512.png
BRAND_WORDMARK_URL=https://your-cdn.com/wordmark.svg
The customer + merchant apps display the logo on splash, login,
profile, and settlement statements (PDFs). The admin panel uses
icon_url for the favicon if set.
Resale workflow
If you're selling deployments to clients, here's the lowest-friction path:
- Clone & brand — fork the repo, edit
brand.config.js+ the client's.envvalues (BRAND_NAME, colors, logo URLs). - Configure providers — drop in the client's regional
adapters (Prembly for NG, Plaid for US, etc.) in
config/services.php. - Deploy — push the backend to your usual host. Run
php artisan migrate --force, set a long-lived APP_KEY, configure Redis. - Publish the apps — bump the bundle ID in
customer-app/ios/Runner.xcodeprojandcustomer-app/android/app/build.gradle, sign with the client's certs, submit. Same for merchant. - Hand off the admin — invite the client's super admin, rotate the seeded creds.
Standard license: 1 deployment. Extended license: client deployments + reselling permitted.
Backend configuration
Three files cover 95% of operator config:
.env— secrets, DB credentials, brand env vars, provider API keysconfig/brand.php— branding (see above)config/services.php— provider keys (Zauthy, PostaLynk, Mono, Prembly, Sudo, FCM, Stripe Issuing, etc.)
Platform-wide settings (collection thresholds, retry intervals, late fee
amounts) live in the platform_settings table — edit them in admin or
seed them in DatabaseSeeder::seedPlatformSettings().
Scheduler & jobs
Add to crontab on production:
* * * * * cd /var/www/flexbuy/backend && php artisan schedule:run >> /dev/null 2>&1
| Job | Cadence | What it does |
|---|---|---|
flexbuy:collect | hourly | Detects overdue installments, advances collection cases, dispatches auto-actions |
flexbuy:auto-debit | daily 09:00 | Attempts debit on installments due today via primary bank account |
flexbuy:reminders | daily 08:00 | Queues T-3 / T-1 / due-today / T+1 / T+7 push + email reminders |
flexbuy:settle | daily 23:30 | Batches merchant payouts, applies MDR, creates settlement rows |
For higher throughput, swap the default sync queue for Redis + Horizon
(already wired): QUEUE_CONNECTION=redis, then php artisan horizon.
Provider adapters
Every external dependency is behind a thin interface. Drop in your
region's implementation by binding to the contract in
App\Providers\FlexBuyServiceProvider:
// Example: replace the default stub with Prembly for Nigerian deployments.
public function register(): void
{
$this->app->bind(BureauProvider::class, PremblyProvider::class);
$this->app->bind(IdentityProvider::class, ZauthyProvider::class);
}
Ships with adapters for:
- KYC — Zauthy (Lumatrx), Prembly
- Credit bureaus — Prembly, CreditRegistry, CRC, generic stub
- Bank linking — Mono, Okra, Plaid, manual
- Virtual cards — Sudo, Bloc, Marqeta, Stripe Issuing
- Email — PostaLynk (custom mailer driver), or any Laravel mailer
- Push — FCM HTTP v1 (when configured)
- Crypto repayment — Zencoinpay (off by default)
Webhooks
Merchants configure endpoints from the admin or via
POST /api/v1/merchant/webhooks. Every event is signed with
HMAC-SHA256 of the body using the merchant's secret:
X-FlexBuy-Signature: 9a4e…
X-FlexBuy-Event: loan.authorized
Retry policy: 8 attempts over 72 hours
([60s, 5m, 15m, 30m, 1h, 2h, 4h, 8h]). Failed deliveries are visible
in admin → Platform → Webhook Deliveries.
Events:
order.created loan.cancelled installment.missed
loan.authorized loan.completed settlement.processed
loan.funded installment.due refund.requested
installment.paid refund.completed
dispute.opened
dispute.resolved
Deployment
Production checklist
APP_ENV=production+APP_DEBUG=false- Set
APP_KEYviaphp artisan key:generate - Switch DB to PostgreSQL:
DB_CONNECTION=pgsql - Configure Redis:
QUEUE_CONNECTION=redis,CACHE_STORE=redis - Run
php artisan storage:linkfor KYC document URLs - Set
FLEXBUY_CORS_ORIGINS=https://your-domain.com - Set up TrustProxies for the load balancer
- Configure crontab for
schedule:run+ Horizon supervisor - Configure your TLS termination + HSTS
Customer app
23 screens. Built with Flutter 3, go_router, dio, flutter_secure_storage, local_auth, image_picker, share_plus, google_fonts.
Configure API base URL
At build time:
flutter run --dart-define=FLEXBUY_API_BASE=https://api.your-domain.com/api/v1
Without override:
- iOS simulator / macOS →
http://localhost:8000/api/v1 - Android emulator →
http://10.0.2.2:8000/api/v1(the Android sim's host loopback) - Web →
http://localhost:8000/api/v1
Security
- Tokens stored in Keychain (iOS) / EncryptedSharedPreferences (Android)
- Biometric unlock via
local_auth(Face ID / Touch ID / fingerprint) - Auto-lock after configurable idle minutes (1 / 5 / 15 / 30 / 60)
- 401 responses auto-purge token and route to /login
Merchant app
16 screens. OTP-only login (no biometric — merchant terminals are shared). Dashboard, sales, settlements, refunds, plans, webhooks, customers, reports.
Configure API base URL same as customer app
(--dart-define=FLEXBUY_API_BASE=…).
Admin panel
Filament 4 panel at /admin. 9 resources (Users, Merchants, Loans,
Plans, KYC Queue, Collections, Settlements, Disputes, Admins) plus a
Portfolio dashboard with 5 widgets (stats, originations chart, aging
doughnut, tier distribution, top-merchants table).
RBAC via Filament Shield — 5 seeded roles (super / underwriter / collections / finance / support), 38 permissions across 8 navigation groups.
Checkout widget
Drop into any merchant checkout:
<script src="https://cdn.your-domain.com/v1.js" async></script>
<div id="flexbuy-button"
data-merchant-key="pk_live_…"
data-amount="14999"
data-currency="USD"
data-order-ref="ORD-A9988"
data-customer-email="[email protected]"
data-success-url="https://shop.example.com/order/complete"
data-cancel-url="https://shop.example.com/cart"></div>
Listen for outcomes:
window.addEventListener('flexbuy.success', (e) => {
// e.detail.loan_id, e.detail.loan_number, e.detail.auth_code
});
window.addEventListener('flexbuy.cancel', () => { /* ... */ });
REST API
86 endpoints under /api/v1/*. Auth via Laravel Sanctum bearer tokens
(customer + merchant) or merchant public-key header (widget).
Quick reference
| Surface | Auth | Endpoints |
|---|---|---|
| Customer | Sanctum (Bearer) | /me, /credit/limit, /loans, /payments, /bank-accounts, /cards, /referrals/*, /preferences/* |
| Merchant | Sanctum (Bearer) | /merchant/sales, /settlements, /refunds, /plans, /webhooks, /api-keys |
| Widget | X-Merchant-Public-Key | /widget/sessions, /sessions/{t}/eligibility, /sessions/{t}/authorize |
| Public | None (rate-limited) | /auth/login, /auth/register, /leads, /brand |
Full reference in api-reference.md.
Email (PostaLynk)
FlexBuy ships with a custom Laravel mail transport for PostaLynk at app/Mail/Transports/PostaLynkTransport.php. Drop in your API key, set MAIL_MAILER=postalynk, and every Mail::to(...) in the codebase delivers via PostaLynk's HTTP API.
Setup
Add to your backend .env:
MAIL_MAILER=postalynk
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
POSTALYNK_API_KEY=mf_live_xxx
POSTALYNK_URL=https://postalynk.com
POSTALYNK_TIMEOUT=15
The sender domain must be verified in your PostaLynk dashboard before sends land in the inbox.
What ships out of the box
| Mailable | Trigger | Queued? |
|---|---|---|
OtpCodeMail | Signup, login, password-reset, payment-confirm, widget-session | No — sendNow for urgency |
WelcomeMail | After signup OTP is verified | Yes |
PaymentReceiptMail | InstallmentPaid event | Yes |
PaymentFailedMail | InstallmentMissed event | Yes |
SettlementMail | SettlementProcessed event (merchant) | Yes |
Queued mailables fire through the standard Laravel queue — run php artisan queue:work in production.
Tagging
Each Mailable adds an X-PostaLynk-Tags header (otp,signup, receipt,payment, …) so you can filter and segment in the PostaLynk dashboard.
Templates
All emails extend resources/views/components/mail/layout.blade.php, which reads config/brand.php for colors, name, support email, terms / privacy links. Rebrand by editing brand.php — no template changes needed.
Testing
# Render a Mailable to HTML without sending
php artisan tinker --execute="echo (new App\Mail\OtpCodeMail('123456','signup',10))->render();"
# Send a real test email through PostaLynk
php artisan tinker --execute="Mail::to('[email protected]')->sendNow(new App\Mail\OtpCodeMail('123456','signup',10));"
Support chat (Supoora)
FlexBuy uses Supoora for live support across every surface — website, docs, admin panel, and both Flutter apps. One embed key controls all of them.
Setup
Add to your backend .env:
SUPOORA_EMBED_KEY=sup_xxx
SUPOORA_WIDGET_URL=https://widget.supoora.com/widget.js
SUPOORA_API_URL=https://api.supoora.com/api
SUPOORA_WS_URL=https://api.supoora.com
Where it shows up
- Landing page —
resources/views/landing.blade.phpinjects the widget script before</body>. - Docs site — same partial:
resources/views/partials/supoora-widget.blade.php. - Filament admin — registered as a
PanelsRenderHook::BODY_ENDhook inAdminPanelProvider. - Flutter apps — native
SupooraClient+ dedicatedSupooraChatScreen. Uses Supoora's REST + Socket.IO directly (no WebView).
How the apps connect
The GET /api/v1/brand endpoint now returns a support block:
{
"support": {
"embed_key": "sup_xxx",
"api_url": "https://api.supoora.com/api",
"ws_url": "https://api.supoora.com"
}
}
BrandService loads this at app boot; SupooraClient reads from it. Switch embed keys per deploy and both apps pick up the change after a brand refresh — no rebuilds.
Fallback behavior
When SUPOORA_EMBED_KEY isn't set, every surface degrades gracefully: web pages skip the script tag, the chat screen shows "Live chat is being set up", admin panel shows nothing.
Auth flows
Customer signup
POST /auth/register— email, phone, password- OTP is dispatched to email + phone
POST /auth/otp/verifywithpurpose=signup— returns Sanctum token
Customer login
POST /auth/login— email + password → token
Customer password reset
POST /auth/forgot-password— emailPOST /auth/forgot-password/verify— read-only verify (does not burn attempts — see security notes)POST /auth/reset-password— email + code + new password
Merchant login
Two-step OTP: email/password → 6-digit code dispatched to email →
POST /merchant/auth/otp/verify returns token.
Security notes
- Password reset verify is non-burning by design. Burning the OTP attempt counter on every verify
would let an attacker permanently lock a victim's reset path by spamming wrong codes from rotating IPs. Brute force is bounded by
(a) per-IP rate limit (10 attempts / 10 min) and (b) the attempt counter inside
OtpCode::verify()on the eventualreset()call. - OTP issue does NOT consume prior pending codes. A hostile actor hitting
/auth/forgot-passwordfor a victim's email won't silently invalidate the victim's in-flight legitimate code. - Webhooks signed with HMAC-SHA256. Verify the
X-FlexBuy-Signatureheader on your endpoint. - Audit log middleware writes one row per mutating
admin / customer / merchant action to
audit_logs. Password / secret fields stripped. - PCI: we never see full PAN. Virtual card data lives at the provider (Sudo, Bloc, Marqeta, Stripe Issuing); we store only last-four and metadata.
- CORS allowlist resolved at request time from
FLEXBUY_CORS_ORIGINSsoconfig:cachecan't freeze a stale value. - Compliance is YOUR job. Consumer credit licensure, e-money rules, and data-protection compliance are the buyer's responsibility.
Troubleshooting
"Invalid email or password" on a known-good account
The customer app's default API base on iOS sim is localhost — on Android emu it's 10.0.2.2.
If you're on iOS and the request 404s, the app is talking to itself, not your backend. Run with
--dart-define=FLEXBUY_API_BASE=http://localhost:8000/api/v1.
Onboarding flow doesn't show again
It's gated by a SharedPreferences flag. Clear:
# iOS sim
xcrun simctl uninstall booted com.example.flexbuyCustomer
# Android emulator
adb shell pm clear com.example.flexbuy_customer
Webhooks aren't firing
Confirm queue is running: php artisan horizon (Redis) or
php artisan queue:work (sync). Check webhook_deliveries for the row and look at response_status /
response_body.
Filament panel 500s
Almost always a missing migration or a stale config cache. Try:
php artisan config:clear && php artisan route:clear && php artisan cache:clear && php artisan migrate
FAQ
Can I really launch in a weekend?
The install script bootstraps in under 30 minutes. The remaining time is for branding, provider configuration, and review. A typical client deployment is one evening of work.
What region is this built for?
Region-agnostic. Currencies, KYC providers, bureaus, payment rails — all pluggable. Ships with adapters for Nigeria (Prembly, Mono), the US (Plaid, Stripe Issuing), and Kenya (Smile Identity). The provider interfaces are documented for adding new regions.
How do I add a new payment-rail provider?
Implement the PaymentRailProvider contract and bind it in
FlexBuyServiceProvider::register(). Existing services (LoanService, AutoDebitService) only know the contract;
the rail switches transparently.
Do I need to handle compliance?
Yes. Source code is a tool — the operating business is yours. Consumer credit, lending licensure, e-money rules, and data-protection compliance are your responsibility.
Can I sell this to my clients?
With the Extended License: yes, for client deployments and reseller deployments. With the Standard License: one production deployment under your own brand.
Can I disable features I don't need?
Yes — see Feature flags. Toggle biometric login, referrals, virtual cards, crypto repayment, the merchant widget, or KYC requirement via env vars without touching code.
What happens to a customer's biometric on logout?
Stored creds in flutter_secure_storage are cleared. The next login starts fresh — the user has to re-opt-in to biometric unlock.