Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Flextuma

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 delivery today with WhatsApp and Email on the roadmap.
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.

---

Expand Down Expand Up @@ -210,6 +210,63 @@ These are `POST` and `PUT` request samples for each shared CRUD resource; the co

For Beem, delivery reports are normally obtained by the polling worker, which uses the submitted `request_id` saved as `providerMessageId`.

### WhatsApp Cloud API

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:

```json
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.

#### Shared system connectors and safe use

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`:

```json
{
"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.

### Planned: Flextuma-managed Meta Tech Provider onboarding

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 |

#### Required product and platform work

1. Complete Meta Tech Provider onboarding, business verification, app review, and the required advanced permissions before making this flow generally available.
2. 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.
3. Add tenant-owned `WhatsAppConnection`, `WhatsAppBusinessAccount`, and `WhatsAppPhoneNumber` records. Store WABA ID, phone-number ID, encrypted access-token reference, lifecycle state, and consent/audit timestamps separately from customer webhook-forwarding settings.
4. 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.
5. 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.
6. 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.

### System administration

These routes require `SUPER_ADMIN` or `ALL` authority.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.44'
version = '0.0.45'
description = 'Flextuma App'

java {
Expand Down
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Provide secrets through the platform secret manager, never in the image, reposit
| `HIKARI_MAX_POOL`, `HIKARI_MIN_IDLE` | Recommended | Size across all replicas below PostgreSQL’s connection limit. |
| `SESSION_TIMEOUT` | Recommended | Session lifetime, e.g. `30m`. |
| `SMS_PRICE_PER_SEGMENT` | Yes | Decimal cost used for wallet accounting; confirm the business unit and currency. |
| `FLEXTUMA_CONNECTOR_ENCRYPTION_KEY` | Yes for connector writes | Base64-encoded 32-byte AES key used to encrypt provider keys, secrets, access tokens, and app secrets at rest. Store and rotate it through the deployment secret manager; it must never be committed. |
| `FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER` | Optional | Maximum messages per user and shared system connector per UTC day; defaults to `1000`. Set `0` only for an intentionally unlimited plan. |
| `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | Optional | Beem delivery-report polling interval in milliseconds; defaults to `60000`. Beem polling starts five minutes after send. |
| `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | Optional | Minimum wait before the first Beem delivery lookup; defaults to `5`, as recommended by Beem. |
| `APP_FRONTEND_DIRECTORY` | If serving UI | Read-only directory containing `index.html` and assets. |
Expand Down
8 changes: 8 additions & 0 deletions docs/third-party-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ The trigger endpoint is `POST /api/webhooks/{connector-config-uuid}/sms`; it fet

## Implementation gaps and recommendations

### WhatsApp managed onboarding (planned)

The current WhatsApp Cloud API implementation is a bring-your-own-Meta integration. It accepts a customer-owned connector and relays webhooks using a Flextuma-generated callback URL and verification token. It is not a Meta Tech Provider / Embedded Signup integration.

To offer no-secret customer onboarding, Flextuma must first obtain the necessary Meta Tech Provider approvals and advanced permissions. The implementation must then add a server-side Embedded Signup exchange, encrypted tenant-scoped credential storage, WABA and phone-number registration, programmatic webhook subscription, Meta asset-to-tenant routing, token/revocation lifecycle handling, and a customer disconnect path. The browser must never receive persistent Meta credentials or the platform app secret.

Until that work is complete, user-facing copy must state that the customer configures their own Meta app and must paste the generated Flextuma callback URL and verification token into WhatsApp Cloud.

These are code-observed findings as of this repository revision, ordered by impact.

| Priority | Finding | Impact and recommended action |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
Expand Down Expand Up @@ -42,6 +46,17 @@ public class PersonalAccessToken extends BaseEntity {

private LocalDateTime expiresAt;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "scopes")
private Set<String> scopes;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "allowed_connector_ids")
private Set<UUID> allowedConnectorIds;

@Column(name = "allow_system_connectors")
private Boolean allowSystemConnectors;

@Transient
private String rawToken;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.flexcodelabs.flextuma.core.helpers.MaskingUtil;
import com.flexcodelabs.flextuma.core.entities.base.Owner;
import com.flexcodelabs.flextuma.core.security.EncryptedStringConverter;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
Expand Down Expand Up @@ -30,17 +31,20 @@ public class SmsConnector extends Owner {
@NotBlank(message = "Provider name is required")
private String provider;

/** For WhatsApp use a Graph API base URL, e.g. https://graph.facebook.com/v21.0. */
@NotBlank(message = "Url is required")
private String url;

@Column(nullable = true)
@Convert(converter = EncryptedStringConverter.class)
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String key;

@Column(name = "isdefault")
private Boolean isDefault = true;

@Column(nullable = true)
@Convert(converter = EncryptedStringConverter.class)
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String secret;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.flexcodelabs.flextuma.core.entities.whatsapp;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.flexcodelabs.flextuma.core.entities.base.Owner;
import com.flexcodelabs.flextuma.core.helpers.MaskingUtil;
import com.flexcodelabs.flextuma.core.security.EncryptedStringConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "whatsapp_webhook_config", uniqueConstraints = @UniqueConstraint(name = "uk_whatsapp_phone_number_id", columnNames = "phone_number_id"))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class WhatsAppWebhookConfig extends Owner {
public static final String PLURAL = "whatsappWebhookConfigs";
public static final String NAME_PLURAL = "WhatsApp Webhook Configurations";
public static final String NAME_SINGULAR = "WhatsApp Webhook Configuration";
public static final String ALL = "ALL";
public static final String READ = ALL, ADD = ALL, DELETE = ALL, UPDATE = ALL;

@NotBlank
@Column(name = "phone_number_id", nullable = false)
private String phoneNumberId;

@NotBlank
@Column(name = "callback_url", nullable = false, columnDefinition = "TEXT")
private String callbackUrl;

@NotBlank
@Column(name = "verify_token", nullable = false)
@jakarta.persistence.Convert(converter = EncryptedStringConverter.class)
private String verifyToken;

@JsonIgnore
@Column(name = "callback_token", nullable = false, unique = true)
private String callbackToken;

/** The unique Meta callback URL generated by Flextuma; copy this to WhatsApp Cloud. */
@Column(name = "meta_callback_url", nullable = false, columnDefinition = "TEXT")
private String metaCallbackUrl;

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "signing_secret")
@jakarta.persistence.Convert(converter = EncryptedStringConverter.class)
private String signingSecret;

/** Meta App Secret used to validate X-Hub-Signature-256 on inbound events. */
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "app_secret")
@jakarta.persistence.Convert(converter = EncryptedStringConverter.class)
private String appSecret;

@JsonProperty("signingSecret") public String getMaskedSigningSecret() { return MaskingUtil.mask(signingSecret); }
@JsonProperty("appSecret") public String getMaskedAppSecret() { return MaskingUtil.mask(appSecret); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ public interface SmsConnectorRepository extends BaseRepository<SmsConnector, UUI

Optional<SmsConnector> findByProviderAndCode(String provider, String code);

Optional<SmsConnector> findByIdAndActiveTrue(UUID id);

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.entities.sms.SmsLog;
import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector;
import com.flexcodelabs.flextuma.core.enums.SmsLogStatus;

@Repository
Expand Down Expand Up @@ -47,6 +48,8 @@ int claimPendingMessage(@org.springframework.data.repository.query.Param("id") U
long countByCreatedByAndStatusInAndCreatedGreaterThanEqual(User user, Collection<SmsLogStatus> statuses,
LocalDateTime created);

long countByCreatedByAndConnectorAndCreatedGreaterThanEqual(User user, SmsConnector connector, LocalDateTime created);

long countByStatus(SmsLogStatus status);

long countByStatusIn(Collection<SmsLogStatus> statuses);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.flexcodelabs.flextuma.core.repositories;

import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface WhatsAppWebhookConfigRepository extends BaseRepository<WhatsAppWebhookConfig, UUID>, JpaSpecificationExecutor<WhatsAppWebhookConfig> {
Optional<WhatsAppWebhookConfig> findByPhoneNumberIdAndActiveTrue(String phoneNumberId);
Optional<WhatsAppWebhookConfig> findByVerifyTokenAndActiveTrue(String verifyToken);
Optional<WhatsAppWebhookConfig> findByCallbackTokenAndActiveTrue(String callbackToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.flexcodelabs.flextuma.core.security;
import java.util.Set;
import java.util.UUID;
public final class ApiTokenContext {
public static final String SEND_MESSAGES = "MESSAGES_SEND";
private static final ThreadLocal<TokenGrant> CURRENT = new ThreadLocal<>();
private ApiTokenContext() { }
public static void set(TokenGrant grant) { CURRENT.set(grant); }
public static TokenGrant get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
public record TokenGrant(Set<String> scopes, Set<UUID> connectorIds, boolean allowSystemConnectors) {
public boolean allows(String scope) { return scopes != null && scopes.contains(scope); }
public boolean allowsConnector(UUID connectorId) { return connectorIds != null && connectorIds.contains(connectorId); }
}
}
Loading