Flextuma is a configurable, multi-tenant messaging gateway built on Spring Boot. It serves multiple organisations from a single deployment with full data isolation, and supports SMS and WhatsApp Cloud API delivery today.
| Requirement | Version |
|---|---|
| Java | 17+ |
| Docker & Docker Compose | Any recent version |
| Gradle | Provided via wrapper (./gradlew) |
The application requires PostgreSQL and Redis to be available before startup. These are not provisioned by the included compose.yaml — they must be provided externally.
git clone <repository-url>
cd flextumaCreate a .env file in the root directory or export the variables in your shell:
| Variable | Required | Default | Description |
|---|---|---|---|
SPRING_DATASOURCE_URL |
✅ | — | JDBC URL, e.g. jdbc:postgresql://host:5432/db |
SPRING_DATASOURCE_USERNAME |
✅ | — | Database username |
SPRING_DATASOURCE_PASSWORD |
✅ | — | Database password |
SPRING_DATA_REDIS_HOST |
❌ | redis |
Redis hostname; used when REDIS_URL is not set |
SPRING_DATA_REDIS_PORT |
❌ | 6379 |
Redis port |
REDIS_URL |
❌ | — | Full Redis URL (for example, redis://:password@redis:6379/0); overrides host and port |
HIKARI_MAX_POOL |
❌ | 10 |
Max JDBC connection pool size |
SMS_PRICE_PER_SEGMENT |
❌ | 20.0 |
Price per SMS segment (in TZS) |
FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS |
❌ | 60000 |
Beem delivery-report polling interval in milliseconds |
FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES |
❌ | 5 |
Minimum delay before the first Beem delivery lookup |
./gradlew clean build -x testdocker compose up --buildThe application starts on http://localhost:8080.
./gradlew bootRun./gradlew build -tFlextuma follows a layered architecture with a shared core library and feature-based modules.
src/main/java/com/flexcodelabs/flextuma/
├── core/
│ ├── config/ # App startup, Jackson, request logging, cookie auth config
│ ├── context/ # TenantContext (ThreadLocal — reserved, not yet active)
│ ├── annotations/ # @FeatureGate — method-level feature flag annotation
│ ├── aspects/ # FeatureGateAspect — AOP enforcement of @FeatureGate
│ ├── controllers/ # BaseController<T, S> — generic CRUD for all modules
│ ├── dtos/ # Pagination<T> response wrapper
│ ├── entities/
│ │ ├── base/ # BaseEntity, NameEntity, Owner (MappedSuperclasses)
│ │ ├── auth/ # User, Role, Privilege, Organisation
│ │ ├── connector/ # ConnectorConfig
│ │ ├── contact/ # Contact
│ │ ├── feature/ # TenantFeature — per-org feature flags
│ │ ├── metadata/ # Tag, ListEntity
│ │ └── sms/ # SmsConnector, SmsTemplate, SmsLog
│ ├── enums/ # AuthType, CategoryEnum, UserType, FilterOperator
│ ├── exceptions/ # Global exception handling
│ ├── helpers/ # Specification builder, filters, masking, template utils
│ ├── interceptors/ # Entity audit interceptor
│ ├── repositories/ # BaseRepository + all JPA repositories
│ ├── security/ # SecurityConfig, SecurityUtils, CustomSecurityExceptionHandler
│ ├── senders/ # SmsSender interface + BeemSender, NextSmsSender
│ └── services/ # BaseService<T>, SmsSenderRegistry, DataSeederService
└── modules/
├── auth/ # User, Role, Privilege, Organisation controllers & services
├── connector/ # ConnectorConfig + DataHydratorService
├── contact/ # Contact management
├── feature/ # TenantFeature — per-org feature flag management
├── metadata/ # Tags and Lists
├── notification/ # Notification management
└── sms/ # SmsConnector, SmsTemplate controllers & services
Base URL: http://localhost:8080. All API routes return JSON unless noted. Substitute real UUIDs for {id}. Authentication is required for every API route except registration, login, and POST /api/webhooks/{provider}. Use the SESSION cookie returned by login, or send a personal access token as X-API-KEY: <token>.
Successful entity responses include the entity's id, audit timestamps, and the fields shown in the request. A representative error response is:
{
"timestamp": "2026-08-16T12:00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed"
}| Endpoint | Sample request | Sample response |
|---|---|---|
POST /api/register |
{"name":"Jane Doe","username":"jane","password":"Secret123!","phoneNumber":"+255700000000","email":"jane@example.com"} |
201 — {"id":"…","username":"jane","email":"jane@example.com","verified":false} |
POST /api/login |
{"username":"jane","password":"Secret123!"} |
200 — {"id":"…","username":"jane","email":"jane@example.com"} and a Set-Cookie: SESSION=… header |
POST /api/logout |
no body | 200 — {"message":"Logged out successfully"} |
GET /api/me |
no body | 200 — {"id":"…","username":"jane","email":"jane@example.com","roles":[]} |
POST /api/verify |
{"identifier":"jane@example.com","code":"123456"} |
200 — {"success":true,"data":"Verification successful"} |
POST /api/resendVerification |
{"identifier":"jane@example.com"} |
200 — {"success":true,"data":"Verification code sent"} |
POST /api/changePassword |
{"currentPassword":"Secret123!","newPassword":"NewSecret123!","confirmPassword":"NewSecret123!"} |
200 — {"success":true,"data":{"id":"…","username":"jane"}} |
Login and registration are rate limited. POST /api/changePassword and GET /api/me require authentication.
The following resource paths implement every operation in this table: /api/users, /api/roles, /api/privileges, /api/organisations, /api/tokens, /api/connectorConfigs, /api/contacts, /api/tenantFeatures, /api/wallets, /api/lists, /api/tags, /api/personalNotifications, /api/campaigns, /api/connectors, /api/smsLogs, and /api/templates.
| Endpoint | Sample request | Sample response |
|---|---|---|
GET {resource}?page=0&size=20&sort=createdAt,desc |
no body | 200 — {"page":1,"total":1,"pageSize":20,"contacts":[{"id":"…","firstName":"Jane"}]} |
GET {resource}/{id} |
no body | 200 — {"id":"…","firstName":"Jane","phoneNumber":"+255700000000"} |
GET {resource}/fields |
no body | 200 — [{"name":"firstName","type":"String","mandatory":false}] |
GET {resource}/aggregate?aggregate=count(id):total&groupBy=status |
no body | 200 — [{"status":"ACTIVE","total":12}] |
POST {resource} |
JSON body from the resource examples below | 200 — the created entity, for example {"id":"…","firstName":"Jane","phoneNumber":"+255700000000"} |
PUT {resource}/{id} |
JSON body from the resource examples below | 200 — the updated entity |
DELETE {resource}/{id} |
no body | 200 — {"message":"contacts deleted successfully"} |
DELETE {resource}/bulky?filter=status:eq:INACTIVE |
no body | 200 — {"message":"3 contacts deleted successfully"} |
For list and aggregate operations, repeat filter to apply multiple filters, use rootJoin=OR to join filters with OR (the default is AND), and use fields=field1,field2 to select fields. Aggregates use function(column):alias, such as sum(balance):total. The resource-specific array key in a list response is the resource name (for example smsLogs, tenantFeatures, or connectorConfigs).
These are POST and PUT request samples for each shared CRUD resource; the corresponding response is the saved object, with an id and audit fields added.
| Resource | Sample request body |
|---|---|
/api/users |
{"name":"Jane Doe","username":"jane","email":"jane@example.com","phoneNumber":"+255700000000","password":"Secret123!","roles":[{"id":"…"}],"organisation":{"id":"…"}} |
/api/roles |
{"name":"OPERATOR","privileges":[{"id":"…"}]} |
/api/privileges |
{"name":"View contacts","value":"CONTACT_READ","system":false} |
/api/organisations |
{"name":"Acme Ltd","phoneNumber":"+255700000000","email":"ops@acme.example","address":"Dar es Salaam","website":"https://acme.example"} |
/api/tokens |
{"name":"integration","user":{"id":"…"},"expiresAt":"2027-01-01T00:00:00"} |
/api/connectorConfigs |
{"tenantId":"tenant-1","url":"https://erp.example","endpoint":"/customers","search":"status=ACTIVE","authType":"BEARER","token":"secret","mappings":[{"systemKey":"phoneNumber","jsonPath":"phone"}]} |
/api/contacts |
{"firstName":"Jane","middleName":"A","surname":"Doe","email":"jane@example.com","phoneNumber":"+255700000000","status":"ACTIVE","lists":[{"id":"…"}],"tags":[{"id":"…"}]} |
/api/tenantFeatures |
{"organisation":{"id":"…"},"featureKey":"SMS","enabled":true} |
/api/wallets |
{"balance":1000.00,"smsCost":20.00,"currency":"TZS","type":"SMS"} |
/api/lists |
{"name":"Customers","description":"Subscribed customers"} |
/api/tags |
{"name":"VIP","description":"High-value contact"} |
/api/personalNotifications |
{"title":"Low balance","message":"Top up your wallet","type":"WARNING","linkUrl":"/wallet","metadata":"{}"} |
/api/campaigns |
{"name":"August promotion","description":"Monthly offer","template":{"id":"…"},"scheduledAt":"2026-08-20T10:30:00","status":"SCHEDULED","recipients":"+255700000000,+255700000001","connector":{"id":"…"}} |
/api/connectors |
{"provider":"beem","url":"https://apisms.beem.africa","key":"api-key","secret":"api-secret","senderId":"FLEXTUMA","isDefault":true,"extraSettings":"{}"} |
/api/smsLogs |
{"recipient":"+255700000000","content":"Hello Jane","status":"PENDING","connector":{"id":"…"},"scheduledAt":"2026-08-20T10:30:00"} |
/api/templates |
{"code":"WELCOME","name":"Welcome","description":"New-user greeting","content":"Hello {{name}}","category":"PROMOTIONAL","system":false} |
POST /api/tokens returns the token entity; retain the returned rawToken securely because it is the value to pass in X-API-KEY. It must not be logged or committed.
| Endpoint | Sample request | Sample response |
|---|---|---|
POST /api/templates/preview |
{"template":"Hello {{name}}, your order {{orderId}} is ready!","variables":{"name":"Jane","orderId":"12345"}} |
200 — {"renderedContent":"Hello Jane, your order 12345 is ready!","segmentCount":1,"encoding":"GSM-7","charactersRemaining":121,"cost":20.00,"pricePerSegment":20.00} |
POST /api/smsLogs/{id}/retry |
no body | 200 — {"id":"…","recipient":"+255700000000","status":"PENDING","retries":1} |
GET /api/notifications?page=1&pageSize=15 |
no body | 200 — {"page":1,"total":1,"pageSize":15,"data":[{"id":"…","phoneNumber":"+255700000000","message":"Hello Jane","status":"PENDING","provider":"beem"}]} |
POST /api/notifications |
{"phoneNumber":"+255700000000","templateCode":"WELCOME","provider":"beem","name":"Jane"} |
200 — {"id":"…","recipient":"+255700000000","content":"Hello Jane","status":"PENDING"} |
POST /api/notifications/raw |
{"phoneNumber":"+255700000000","message":"Direct message","provider":"beem"} |
200 — {"id":"…","recipient":"+255700000000","content":"Direct message","status":"PENDING"} |
GET /api/personalNotifications/summary?pageSize=5 |
no body | 200 — {"unreadCount":2,"notifications":[{"id":"…","title":"Low balance","readAt":null}]} |
POST /api/personalNotifications/{id}/read |
no body | 200 — {"id":"…","title":"Low balance","readAt":"2026-08-16T12:00:00"} |
POST /api/personalNotifications/readAll |
no body | 200 — {"updated":2} |
GET /api/dashboard/summary |
no body | 200 — {"userId":"…","username":"jane","sent":12,"failed":1,"balanceAmount":980.00,"balance":"980.00","currency":"TZS","activeCampaigns":1,"today":3,"thisWeek":12,"thisMonth":13,"successRate":92.31,"statusBreakdown":{"sent":92.31,"failed":7.69,"pending":0.0,"other":0.0}} |
POST /api/webhooks/{provider} is unauthenticated; all other webhook routes require authentication.
| Endpoint | Sample request | Sample response |
|---|---|---|
POST /api/webhooks/beem |
{"request_id":"provider-message-id","status":"DELIVERED","timestamp":"2026-08-16T12:00:00Z"} |
200 with an empty body |
POST /api/webhooks/{connectorConfigId}/sms |
{"provider":"beem","templateCode":"WELCOME","filterQuery":{"status":"ACTIVE"}} |
200 — {"message":"Successfully queued messages","queued":10,"totalFetched":10} |
POST /api/webhooks/{connectorConfigId}/sms (raw) |
{"provider":"beem","content":"Custom message","filterQuery":{"status":"ACTIVE"}} |
200 — {"message":"Successfully queued messages","queued":10,"totalFetched":10} |
For Beem, delivery reports are normally obtained by the polling worker, which uses the submitted request_id saved as providerMessageId.
Create a normal /api/connectors record with provider: "WHATSAPP", key set to the Meta access token, senderId set to the Meta phone-number ID, and url set to the Graph API base URL (for example https://graph.facebook.com/v21.0). The token is write-only/masked after creation. Send a text message with:
POST /api/notifications/whatsapp
{ "phoneNumber": "+255700000000", "message": "Hello from WhatsApp" }The message is queued and tracked in /api/smsLogs alongside SMS; WhatsApp delivery IDs and delivered/read/failed events update that log.
When a customer does not have an active connector for the selected provider, Flextuma can intentionally fall back to a matching {PROVIDER}_SYSTEM connector. This is Flextuma's paid shared infrastructure, not access to another customer's credentials. The send is always attributed to the authenticated user and must debit that user's wallet before a message log is queued. The charge is the configured per-segment price multiplied by the actual segment count.
Clients may optionally send connectorId with a notification request to select an active connector explicitly. Flextuma rejects a non-system connector unless it belongs to the authenticated user, and rejects it if its provider differs from the requested provider. Connector credentials (key and secret) and WhatsApp verification/app/signing secrets are AES-GCM encrypted in the database when FLEXTUMA_CONNECTOR_ENCRYPTION_KEY is configured; credentials remain write-only and masked in all API responses.
Existing plaintext connector secrets remain readable during a controlled migration. Set the encryption key first, then re-save each connector (or run an approved one-time migration) to encrypt those rows. Do not rotate or remove an encryption key until every record using it has been re-encrypted with the replacement key.
For API automation, create a scoped personal access token with scopes: ["MESSAGES_SEND"]. A scoped token must also list its customer-owned allowedConnectorIds, or set allowSystemConnectors: true to spend the caller's wallet on Flextuma shared connectors. Tokens created before connector grants were introduced remain legacy role-based tokens; rotate them to a scoped token before enforcing this policy globally.
To deliver inbound WhatsApp messages and status events into a user's system, create /api/whatsappWebhookConfigs:
{
"phoneNumberId": "META_PHONE_NUMBER_ID",
"callbackUrl": "https://customer.example.com/webhooks/whatsapp",
"appSecret": "Meta App Secret used to verify inbound signatures",
"signingSecret": "optional-customer-shared-secret"
}Set FLEXTUMA_PUBLIC_BASE_URL to Flextuma's public HTTPS origin before creating configurations. On creation, Flextuma generates and persists a random verifyToken and a unique metaCallbackUrl. Copy these two returned values directly into WhatsApp Cloud's callback URL and verify-token fields; users do not need to create or manage secrets for Meta verification. The generated URL binds Meta's request to that user's configuration, and Flextuma also checks the configured phoneNumberId. Flextuma validates Meta's X-Hub-Signature-256 whenever appSecret is set, then forwards the original JSON to the owning user's callbackUrl. When signingSecret is supplied, the forwarded request includes X-Flextuma-Signature-256: sha256=<HMAC-SHA256(raw-body)> and X-Flextuma-Event: whatsapp. Callback URLs must be HTTPS. The callback endpoint must return quickly with a 2xx response; failed relays are logged and do not trigger a Meta retry, preventing duplicate downstream processing.
The configuration above is the current bring-your-own-Meta integration: customers supply their Cloud API credentials and phone-number ID. The planned Tech Provider mode will offer a branded Connect WhatsApp flow through Meta Embedded Signup, so customers do not manually handle access tokens, app secrets, callback URLs, or verification tokens.
| Area | Current bring-your-own-Meta mode | Planned Tech Provider mode |
|---|---|---|
| Customer action | Create a connector and webhook configuration | Complete Meta Embedded Signup inside Flextuma |
| Credentials | Customer enters and rotates them | Flextuma obtains and stores them server-side, encrypted at rest |
| Callback setup | Flextuma generates callback URL/token; customer pastes them into Meta | Flextuma subscribes the WABA and phone number programmatically |
| Webhook validation | Optional per-customer Meta app secret | Platform-owned Meta app secret validation, then signed relay to the customer |
| Sender lifecycle | Customer configures the phone-number ID | Flextuma registers the phone number, maps WABA/number to the tenant, and manages disconnect/revocation |
- Complete Meta Tech Provider onboarding, business verification, app review, and the required advanced permissions before making this flow generally available.
- Add an Embedded Signup page that exchanges the short-lived authorization result only on the server; no Meta credential may be exposed to the browser, logs, exports, or API responses.
- Add tenant-owned
WhatsAppConnection,WhatsAppBusinessAccount, andWhatsAppPhoneNumberrecords. Store WABA ID, phone-number ID, encrypted access-token reference, lifecycle state, and consent/audit timestamps separately from customer webhook-forwarding settings. - Register phone numbers and subscribe the connected WABA to Flextuma's platform webhook after signup. Use a single platform callback endpoint, verify every POST using
X-Hub-Signature-256, and resolve the tenant from the Meta asset IDs rather than an untrusted callback parameter. - Add lifecycle handling for token expiry/rotation, Meta permission revocation, number disconnection, quality/rate-limit events, subscription repair, and safe idempotent retries of provisioning calls.
- Add template synchronization/approval state, customer opt-in evidence, sender-quality visibility, usage/billing boundaries, support tooling, and a customer-controlled disconnect action that revokes access and disables message sends.
The manually configured mode must remain available during rollout and for customers that use their own Meta application. Do not represent Tech Provider onboarding as implemented until Meta approval and live end-to-end onboarding evidence are available.
These routes require SUPER_ADMIN or ALL authority.
| Endpoint | Sample request | Sample response |
|---|---|---|
GET /api/systemLogs?page=0&size=20&level=ERROR&source=SMS&traceId=abc&from=2026-08-01T00:00:00&to=2026-08-16T23:59:59 |
no body | 200 — {"page":1,"total":1,"pageSize":20,"systemLog":[{"id":"…","level":"ERROR","source":"SMS","message":"Delivery failed"}]} |
GET /api/systemLogs/tail?level=ERROR |
Header: Accept: text/event-stream |
200, SSE stream such as data:{"level":"ERROR","message":"Delivery failed"} |
GET /api/systemLogs/health |
no body | 200 — {"status":"ONLINE","uptime":"0d 1h 2m 3s","uptimeMs":3723000,"memory":{"totalMb":256,"freeMb":120,"usedMb":136,"maxMb":512},"activeThreads":24,"availableProcessors":8,"retentionDays":30} |
DELETE /api/systemLogs/purge?days=30 |
no body | 200 — {"message":"42 log entries purged","olderThanDays":30} |
POST /api/apps requires SUPER_ADMIN authority and accepts multipart/form-data.
curl -X POST http://localhost:8080/api/apps \
-H 'X-API-KEY: <token>' \
-F 'zipFile=@application.zip' \
-F 'appName=myapp' \
-F 'version=1.0.0' \
-F 'overwrite=true'Example response (200):
{
"message": "Application uploaded and extracted successfully",
"appName": "myapp",
"version": "1.0.0",
"extractedPath": "/tmp/apps/myapp/1.0.0",
"fileSize": 2048,
"extractedFiles": 12
}| Endpoint | Sample request | Sample response |
|---|---|---|
GET / |
Header: Accept: text/html |
200 — the frontend index.html document |
GET /assets/{filename} |
GET /assets/app.js |
200 — the requested static asset, for example JavaScript with Content-Type: application/javascript |
GET /** |
GET /campaigns with Accept: text/html |
200 — the frontend index.html SPA shell; non-HTML unknown paths return 404 |
All entities extend one of:
| Class | Adds |
|---|---|
BaseEntity |
id (UUID), created, updated, active, code |
NameEntity extends BaseEntity |
name, description |
Owner extends BaseEntity |
createdBy (User), updatedBy (User) with @CreatedBy audit |
Every resource gets full CRUD for free by extending these:
| HTTP Method | Endpoint | Action |
|---|---|---|
GET |
/api/{resource} |
Paginated list with optional filter and fields params |
GET |
/api/{resource}/{id} |
Get by ID |
POST |
/api/{resource} |
Create |
PUT |
/api/{resource}/{id} |
Update (null-safe partial update) |
DELETE |
/api/{resource}/{id} |
Delete (with optional pre-delete validation) |
Filter syntax: ?filter=field:OPERATOR:value — supports EQ, NE, LIKE, ILIKE, IN, GT, LT.
Every resource defines permission constants (READ_*, ADD_*, UPDATE_*, DELETE_*). BaseService checks these against the current user's granted authorities before every operation. Users with SUPER_ADMIN or ALL bypass all checks.
Flextuma supports per-organisation feature flags via the @FeatureGate AOP annotation. This lets you gate specific capabilities per tenant without a code deploy — useful for subscription tiers, beta rollouts, or temporarily suspending access.
- Annotate any service method with
@FeatureGate("FEATURE_KEY") - Spring AOP intercepts the call and checks the
tenantfeaturetable for the calling user's organisation - If a record with
enabled = falseexists →403 Forbiddenis thrown before the method runs - If no record exists → the feature is allowed (default-open: you only need records for restrictions)
- Users with no organisation (SUPER_ADMIN, system users) always bypass the check
Step 1. Pick a SCREAMING_SNAKE_CASE key and annotate the service method:
// modules/notification/services/NotificationService.java
@Async
@FeatureGate("BULK_CAMPAIGN")
public void sendCampaign(Campaign campaign, String username) {
// 403 thrown here automatically if org has BULK_CAMPAIGN disabled
}Step 2. Add it to the feature keys table in this README (see below).
That's it. No DB schema changes, no config files.
Feature flags and permissions work together but guard different things:
| Layer | Enforced by | Question answered |
|---|---|---|
| Permission | BaseService.checkPermission() |
Does this user's role allow this action? |
| Feature flag | @FeatureGate AOP |
Does this organisation's plan include this capability? |
@FeatureGate("BULK_CAMPAIGN") // ← org-level: is this feature enabled for the tenant?
public void sendCampaign(...) {
checkPermission("SEND_BULK"); // ← user-level: does the user have the right role?
...
}| Scenario | Result |
|---|---|
User lacks SEND_BULK role |
checkPermission() throws 403 |
| User has role, but org is restricted | @FeatureGate throws 403 |
| User has role AND org has feature | ✅ Proceeds |
### Create a restriction (disable a feature for an org)
POST /api/tenantFeatures
Content-Type: application/json
{
"organisation": { "id": "<org-uuid>" },
"featureKey": "WHATSAPP_SEND",
"enabled": false
}
### Re-enable (e.g. after plan upgrade)
PUT /api/tenantFeatures/<feature-uuid>
Content-Type: application/json
{ "enabled": true }
### List all flags for inspection
GET /api/tenantFeatures?filter=organisation:EQ:<org-uuid>Document every key here when you introduce it:
| Key | Controls | Default |
|---|---|---|
BULK_CAMPAIGN |
Bulk messaging to contact lists/tags | Open |
WHATSAPP_SEND |
WhatsApp channel sending | Open |
EMAIL_SEND |
Email channel sending | Open |
CONNECTOR_PULL |
Fetching contacts via external connector | Open |
Convention: All features are open by default. Only create
TenantFeaturerecords when you need to restrict an org. This keeps the table minimal and the logic simple.
| Client Type | Method | Usage |
|---|---|---|
| Browser/SPA | Session-based auth via POST /api/login |
Receives HttpOnly SESSION cookie (Redis-backed) |
| API/Testing | HTTP Basic Auth (Authorization: Basic base64(user:pass)) |
Also creates session for convenience |
| Integrations | Personal Access Token (PAT) | Token-based auth for automated systems |
Authentication Endpoints:
- Registration: Blocks after excessive attempts with configurable timeout
- Login: Prevents brute force attacks with progressive delays
- Verification: Limits resend attempts to prevent abuse
Rate Limit Response:
{
"error": "Rate limit exceeded",
"message": "Too many attempts. Try again in 300 seconds.",
"retryAfter": 300
}CSRF protection is disabled. Authenticate browser requests with the SESSION cookie and API requests with X-API-KEY.
- Storage: Redis-based session persistence
- Cookie:
SESSION, HttpOnly,SameSite=Lax - Concurrency: Maximum 1 concurrent session per user
- Timeout: Configurable session expiration
All security events are automatically logged:
- Login attempts (success/failure)
- Registration attempts
- Password changes
- Logout events
- Verification attempts
Log Format:
{
"timestamp": "2024-01-15T10:30:00Z",
"level": "INFO",
"source": "AUTH",
"event": "LOGIN_SUCCESS",
"username": "john.doe",
"ipAddress": "192.168.1.100",
"userAgent": "Mozilla/5.0...",
"traceId": "abc123-def456"
}Tenant-Aware Filtering:
- SUPER_ADMIN/ALL: Sees all records (no restriction)
- Organisation Users: Sees own records + org-wide data
- Unaffiliated Users: Sees only personal records
- System Entities: No filtering applied (e.g., Organisation)
Implementation: Automatic TenantAwareSpecification in BaseService
Manages users, roles, privilege-based RBAC, organisation membership, and API tokens.
User— linked to anOrganisation(one-to-many: many users per org).UserTypeenum (e.g.SYSTEM) identifies platform-level admins.Organisation— the multi-tenancy anchor. Each SACCO is one Organisation. All users of that SACCO share the sameorganisationId.Role→Privilege— fine-grained permission strings enforced inBaseService.PersonalAccessToken— API tokens for integrations and automated systems.
Additional Endpoints:
/api/register- User registration with verification/api/login- Authentication with rate limiting/api/logout- Session termination/api/me- Current user profile/api/verify- Email/phone verification/api/changePassword- Password management
Manages organizational wallets and SMS billing.
Wallet— per-organisation SMS credit balance with real-time updatesWalletTransaction— complete audit trail of all credit movements
Features:
- Multi-currency support (TZS, USD, etc.)
- Per-segment cost calculation
- Automatic credit deduction on SMS delivery
- Registration bonus credit allocation
- Pre-flight balance checks
Comprehensive SMS management with campaigns, templates, and delivery tracking.
SmsConnector— provider configuration (URL, API key/secret, sender ID, extra settings). One connector can be marked active at a time.SmsTemplate— message templates with{placeholder}variables, categorised byCategoryEnum(PROMOTIONAL, etc.). System templates are protected from deletion.SmsLog— records every sent message: recipient, content, status, provider response, error, and linked template.SmsCampaign— scheduled bulk messaging with status tracking (DRAFT, SCHEDULED, RUNNING, COMPLETED).SmsSendResult— standardized result object containing success/failure status, message ID, error codes, and full provider response data.SmsSenderRegistry— selects the activeSmsConnectorfrom the DB, finds the matchingSmsSenderimplementation by provider name, and dispatches the message.
Advanced Features:
- Template preview with cost calculation (
/api/templates/preview) - Failed message retry (
/api/smsLogs/{id}/retry) - Character encoding detection (GSM-7 vs UCS-2)
- Segment-based billing
Real-time notification dispatch and queue management.
- Template-based SMS - Send templated messages with variable substitution
- Raw SMS Sending - Direct content delivery without templates
- Queue Management - Async processing with status tracking
System monitoring, logging, and application management.
SystemLog— structured logging with filtering, search, and real-time streaming- App Management — application upload and plugin system
Features:
- Real-time log streaming via Server-Sent Events
- Log level filtering (ERROR, WARN, INFO, DEBUG)
- System health monitoring
- Log purging by date range
- Application package upload (SUPER_ADMIN only)
External integrations and delivery report handling.
- Delivery Report (DLR) Receiver — Accepts status updates from SMS providers
- Recipient Resolver Trigger — External ERP integration for bulk messaging
Supported Providers:
beem- Beem SMS provider DLRsnext- NextSMS provider DLRs
Configures how Flextuma connects to each organisation's external ERP/data source.
ConnectorConfig— stores the base URL, endpoint,AuthType(NONE,BASIC,BEARER,API_KEY), credentials (masked in responses), and a JSONPath mapping list (List<FieldMapping>) stored as JSONB.DataHydratorService— given atenantIdand amemberId, fetches the external ERP, applies the JSONPath mappings, and returns aMap<String, String>of system keys to values. Used to populate SMS template placeholders.
Contact and recipient management for messaging campaigns.
Per-organisation feature flag management for subscription tiers and access control.
Tag and list management for organizing contacts and content.
Two concrete SmsSender implementations:
| Provider | Class | Auth Method | Status |
|---|---|---|---|
| Beem | BeemSender + BeemDeliveryReportWorker |
API key + secret (HTTP Basic); delivery-report polling | ✅ Production ready |
| NextSMS | NextSmsSender |
API key + secret (Basic Auth header) | ✅ Production ready |
Adding a new provider: implement SmsSender, annotate with @Service, and set the matching provider string on the SmsConnector record.
All SMS providers now return standardized SmsSendResult objects that include:
- Success/Failure Status - Boolean success flag with descriptive messages
- Message ID - Provider-specific message identifier for tracking
- Error Codes - Standardized error codes for failure scenarios
- Full Provider Response - Complete response data as
Map<String, Object>for debugging and audit
Response Processing Flow:
Provider HTTP Response → SmsSender.processResponse() → SmsSendResult → SmsLog.providerResponse
Key Features:
- Type-safe response mapping using Jackson
Map<String, Object>conversion - Automatic error extraction from provider error responses
- Detailed logging of provider responses for audit trails
- Consistent error handling across all SMS providers
Request with memberId
→ ConnectorConfigRepository.findByTenantId(tenantId)
→ Build URL: config.url + config.endpoint.replace("{id}", memberId)
→ Apply auth headers (BEARER / API_KEY / BASIC / NONE)
→ Parse JSON response with Jayway JsonPath
→ Map to internal keys via FieldMapping list
→ Return Map<String, String> for template rendering
| Client | Method |
|---|---|
| Browser / SPA | Session-based: POST credentials to /api/login → receive HttpOnly SESSION cookie (backed by Redis) |
| API/testing | HTTP Basic Auth (Authorization: Basic base64(user:pass)) — also accepted for session creation |
| API clients | Personal access token in the X-API-KEY header |
| Webhooks | Unauthenticated provider callback (POST /api/webhooks/{provider}) |
CSRF protection is disabled. Browser clients authenticate with the HttpOnly session cookie; API clients can use X-API-KEY.
Every paginated and list query automatically applies TenantAwareSpecification:
| User | Sees |
|---|---|
SUPER_ADMIN or ALL authority |
All records (no restriction) |
| User with an Organisation | Records they created or records created by any member of the same organisation |
| User with no Organisation | Only their own records |
Entities without createdBy (e.g. Organisation) |
No restriction applied |
This is enforced in BaseService.buildTenantSpec() — all subclass services benefit automatically.
- Sessions are stored in Redis (
@EnableRedisHttpSession) - Session cookie:
SESSION, HttpOnly,SameSite=Lax - Maximum 1 concurrent session per user
On startup, DataInitializer runs DataSeederService.seedSystemData(), which executes seed.sql via JDBC to ensure system-level data (privileges, default roles, system user) is present before the application accepts requests.
./gradlew testHTTP request files are in the /http directory. Use IntelliJ's HTTP client or any compatible tool. CSRF is disabled, so no X-CSRF-TOKEN header is needed; authenticate subsequent requests with the session cookie or X-API-KEY.
### Login
POST http://localhost:8080/api/login
Content-Type: application/json
{"username": "admin", "password": "pass"}- Create an entity in
core/entities/extendingBaseEntity,NameEntity, orOwner - Define permission constants (
READ_*,ADD_*, etc.) on the entity - Create a
JpaRepositoryincore/repositories/ - Create a
Service extends BaseService<YourEntity>inmodules/.../services/ - Create a
Controller extends BaseController<YourEntity, YourService>inmodules/.../controllers/
See ROADMAP/roadmap.md for the full development roadmap, ROADMAP/architecture.md for the multi-channel notification architecture, and ROADMAP/roadmap-audit.md for the current implementation status of each item.
Recently completed:
- Admin Monitoring API enhancements (query by status, retry endpoint)
- Scheduling Engine (future-dated campaigns)
- Personal Access Token (PAT) entity and filter for API / gateway access
- Per-organisation feature flagging via
@FeatureGateAOP annotation -
TenantAwareSpecification— automatic org-scoped data isolation -
DataHydratorService— external ERP integration with JSONPath field mapping - Template placeholder engine (
{{variable}}syntax with missing-variable detection) - SMS segment calculator (GSM-7 vs Unicode encoding)
- Wallet & ledger system with pre-flight balance checks
- Async SMS dispatch worker (
@Scheduled+SmsLogstatus lifecycle) - Rate Limiter (Bucket4j per-tenant quotas)
- Webhook DLR receiver & Recipient Resolver Trigger API (
/api/webhooks...) - Character Count & Preview API (
/api/templates/previewreturning segment counts andcharactersRemainingbudget) - Real HTTP implementation for
NextSmsSenderwith provider response logging - Standardized
SmsSendResultservice with type-safe response handling
Immediate next steps:
- Database Partitioning for
sms_logtable - Multi-channel support (WhatsApp/Email)
The new WalletService handles crediting and debiting of accounts per organisation.
Currently, wallets must be topped up programmatically until an admin UI is built.
Example of topping up an account with 100,000 TZS dynamically inside a Service:
@Autowired
private WalletService walletService;
public void processManualTopup(User orgAdmin) {
BigDecimal amount = BigDecimal.valueOf(100000.00);
walletService.credit(orgAdmin, amount, "Manual Top Up", "REF-12345");
}