FlexBuy docs
FlexBuy Documentation

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.

5Components
36DB tables
86API endpoints
39App screens

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

EmailRole
[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:

EmailKYCLoans
[email protected]Verified5
[email protected]Verified5
[email protected]Verified7

Merchant app

One owner per seeded merchant. Login is 2-step: email/password → OTP.

EmailMerchant
[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):

TokenEnvDefault
primaryBRAND_COLOR_PRIMARY#0A0F1E
accentBRAND_COLOR_ACCENT#E0FF6B
successBRAND_COLOR_SUCCESS#12A37C
warningBRAND_COLOR_WARNING#E6A23C
dangerBRAND_COLOR_DANGER#E0464E
surfaceBRAND_COLOR_SURFACE#FAFBFC
text_primaryBRAND_COLOR_TEXT_PRIMARY#0A0F1E
text_mutedBRAND_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:

FlagDefaultEffect
biometric_logintrueHide Face ID / fingerprint on login
referralstrueHide the Referrals screen + endpoints
virtual_cardstrueHide the Cards tab
crypto_repaymentfalseShow Zencoinpay payment method
merchant_widgettrueAllow widget sessions
kyc_requiredtrueBlock 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:

  1. Clone & brand — fork the repo, edit brand.config.js + the client's .env values (BRAND_NAME, colors, logo URLs).
  2. Configure providers — drop in the client's regional adapters (Prembly for NG, Plaid for US, etc.) in config/services.php.
  3. Deploy — push the backend to your usual host. Run php artisan migrate --force, set a long-lived APP_KEY, configure Redis.
  4. Publish the apps — bump the bundle ID in customer-app/ios/Runner.xcodeproj and customer-app/android/app/build.gradle, sign with the client's certs, submit. Same for merchant.
  5. 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 keys
  • config/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
JobCadenceWhat it does
flexbuy:collecthourlyDetects overdue installments, advances collection cases, dispatches auto-actions
flexbuy:auto-debitdaily 09:00Attempts debit on installments due today via primary bank account
flexbuy:remindersdaily 08:00Queues T-3 / T-1 / due-today / T+1 / T+7 push + email reminders
flexbuy:settledaily 23:30Batches 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_KEY via php artisan key:generate
  • Switch DB to PostgreSQL: DB_CONNECTION=pgsql
  • Configure Redis: QUEUE_CONNECTION=redis, CACHE_STORE=redis
  • Run php artisan storage:link for 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

SurfaceAuthEndpoints
CustomerSanctum (Bearer)/me, /credit/limit, /loans, /payments, /bank-accounts, /cards, /referrals/*, /preferences/*
MerchantSanctum (Bearer)/merchant/sales, /settlements, /refunds, /plans, /webhooks, /api-keys
WidgetX-Merchant-Public-Key/widget/sessions, /sessions/{t}/eligibility, /sessions/{t}/authorize
PublicNone (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

MailableTriggerQueued?
OtpCodeMailSignup, login, password-reset, payment-confirm, widget-sessionNo — sendNow for urgency
WelcomeMailAfter signup OTP is verifiedYes
PaymentReceiptMailInstallmentPaid eventYes
PaymentFailedMailInstallmentMissed eventYes
SettlementMailSettlementProcessed 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.php injects the widget script before </body>.
  • Docs site — same partial: resources/views/partials/supoora-widget.blade.php.
  • Filament admin — registered as a PanelsRenderHook::BODY_END hook in AdminPanelProvider.
  • Flutter apps — native SupooraClient + dedicated SupooraChatScreen. 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

  1. POST /auth/register — email, phone, password
  2. OTP is dispatched to email + phone
  3. POST /auth/otp/verify with purpose=signup — returns Sanctum token

Customer login

  1. POST /auth/login — email + password → token

Customer password reset

  1. POST /auth/forgot-password — email
  2. POST /auth/forgot-password/verify — read-only verify (does not burn attempts — see security notes)
  3. 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 eventual reset() call.
  • OTP issue does NOT consume prior pending codes. A hostile actor hitting /auth/forgot-password for a victim's email won't silently invalidate the victim's in-flight legitimate code.
  • Webhooks signed with HMAC-SHA256. Verify the X-FlexBuy-Signature header 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_ORIGINS so config:cache can'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.