Repository navigation
Messaging and Integrations
Messaging is an external side effect and must be treated as a controlled boundary.
The initial channel is WhatsApp, but the domain should not be coupled directly to one provider SDK or one transport.
Core application logic should depend on a narrow contract such as:
MessagingProvider
The initial adapter, GraphApiMessagingProvider (ADR 0027), sends approved templates through the Meta WhatsApp Cloud API (Graph API v23.0, available until 2027-10-08). It is a transport only: one bounded POST to the allow-listed Graph host with the tenant's own credential, and no retry. Future adapters may target other providers or channels without changing follow-up policy.
The provider abstraction should expose application-level semantics, not leak arbitrary provider JSON into the domain.
ADR 0024 freezes the v1 messaging contract that both Phase 3 tracks build against:
- HTTP records in
messaging.api.MessagingApiContract, mirrored byfrontend/src/features/messages/types; - synthetic fixtures in
backend/src/test/resources/contracts/messaging/v1/, checked byMessagingContractFixturesTest(backend) andcontractFixtures.test.ts(frontend), so an incompatible change fails both workflows; - ports with fail-closed rules:
OutboundSafetyControl(kill switches, integration enabled, sender identity),TemplateMappingResolver,IntegrationSecretResolver(secrets never leaveintegration) andVerifiedWebhookEvent; - the approval fingerprint (
approval/v2): an approval binds recipient, content, template mapping revision, exact parameter values, sending identity and consent revision; if any of them changes before send, the send is refused withAPPROVAL_INVALIDATEDand the operator cancels and resubmits.
The message state machine is durable and explicit. ADR 0023 fixes the states and the sixteen transitions; OutboundMessage in backend/src/main/java/io/github/stevdrey/dokene/messaging/domain is the only place a status changes.
PENDING_APPROVAL
↓ approve
APPROVED
↓ request send
QUEUED
↓ start attempt
SENDING
↓ provider accepted
SENT
↓ delivery report
DELIVERED
↓ delivery report
READ
with alternate outcomes:
REJECTED (operator, from PENDING_APPROVAL)
CANCELLED (operator, from PENDING_APPROVAL or APPROVED)
FAILED (permanent provider failure, attempt limit reached, or an operator resolving an unknown outcome)
There is no DRAFT state. A draft, whether an AI suggestion or operator text, lives only in the browser until the operator submits it; submission creates the message directly in PENDING_APPROVAL. Every message is born there, also when a tenant later enables automated sending.
READ, REJECTED, CANCELLED and FAILED are terminal. Every other state counts as open, and a partial unique index (one_open_message_per_customer) keeps at most one open message per customer per tenant.
Transitions are validated in the aggregate; arbitrary status mutation is refused with INVALID_TRANSITION. Each applied transition advances the message version by one and appends one row to the event log, so the latest event sequence number always equals the message version.
The review routes (#136) wrap the T1 to T5 services with the frozen v1 records (MessageController, MessagingExceptionHandler):
| Route | Permission | Notes |
|---|---|---|
POST /api/customers/{customerId}/messages |
MESSAGE_DRAFT |
Submit (T1). If-Match is the customer's follow-up policy version, Idempotency-Key required. 201 with Location, replay 200. |
GET /api/messages |
MESSAGE_READ |
status (repeatable), customerId, cursor, limit 1 to 100. Items carry no body or phone. |
GET /api/messages/{messageId} and /events
|
MESSAGE_READ |
Detail with strong ETag; append-only history without membership ids. |
POST /api/messages/{messageId}/approvals |
MESSAGE_APPROVE |
{ decision: APPROVE | REJECT, note? } (T2, T3). Approval calls no provider. |
POST /api/messages/{messageId}/cancellations |
MESSAGE_DRAFT or MESSAGE_APPROVE
|
{ note? } (T4, T5). Either permission passes the route; the service then requires MESSAGE_DRAFT for a pending message and MESSAGE_APPROVE for an approved one. |
Rules that hold for every route:
- No edit route. Body, recipient, intent and locale are immutable once submitted. To change the text, cancel and submit a new message; the new message needs its own approval.
-
Strict bodies. Unknown members, unknown enum values, numbers for enums or text, and missing required members are
400 INVALID_INPUT, so a client cannot set a status, a recipient or a fingerprint. -
Authorization first. Permissions are checked before headers or bodies are read, so a
VIEWERgets403even for a malformed request. Another tenant's ids are404. -
Preconditions.
If-Matchmust be one strong numeric ETag ("3"); a stale one is409 STALE_VERSION. Replays follow ADR 0023 §4.2; a reused key with another payload or target is409 IDEMPOTENCY_KEY_REUSED. -
Responses. Every command answers with the current detail representation and its
ETag. Message responses areCache-Control: no-storebecausecontactRemovedis derived live. Refusals are closed-codeapplication/problem+json; an audit failure is503with an empty body and rolls the change back.
Approval (T2) re-checks customer status, do-not-contact, WhatsApp consent, eligibility, the body, the template mapping and the open-message rule from stored state under the customer lock. It then computes the approval fingerprint and stores it on the approval row (V22). The fingerprint is never returned, audited or logged. Template parameters are rendered from a closed vocabulary only:
| Parameter name | Value |
|---|---|
customer_name |
The customer's current display name |
message_body |
The submitted body, unchanged |
A mapping with any other parameter name, or written in a different language from the message locale (es/es_MX for es-419), is refused with TEMPLATE_NOT_MAPPED. A rendered value the provider would reject (a line break, a run of five spaces) is refused with BODY_REJECTED. A tenant without a configured integration is refused with INTEGRATION_DISABLED; the kill switches are not checked at approval.
| Table | Purpose | Runtime grants |
|---|---|---|
outbound_messages |
One row per message: status, body, recipient snapshot (contact_id plus recipient_phone, with no foreign key to the contact row so phone corrections still work; readers derive contactRemoved from whether the contact row still exists), send key, attempt count, provider message id, timestamps, version. |
SELECT, INSERT, UPDATE |
outbound_message_approvals |
One row per approve or reject decision, with the operator note and, for an approval, its fingerprint (V22). | SELECT, INSERT |
outbound_message_cancellations |
One row per cancellation, with the operator note. | SELECT, INSERT |
outbound_send_attempts |
One row per provider call, committed before the call and completed after it. | SELECT, INSERT, UPDATE |
outbound_message_events |
Append only log of every transition and every ignored delivery report. | SELECT, INSERT |
outbound_message_idempotency_keys |
(tenant, operation, key) with a request fingerprint, so a replay returns the first result and a reuse with a different payload is refused. |
SELECT, INSERT |
All six tables carry forced row level security with the standard five policies, and the runtime role never holds DELETE. Operator notes stay in the approval and cancellation rows and never reach the audit log or application logs.
V19 extends the audit log with MESSAGE_* event types and closed transition metadata (status from, status to, failure category, attempt number). Delivery reports are audited with tenant only attribution under a provider scoped signed context; no member is credited for a provider callback.
External sends must remain safe under:
- scheduler retries;
- HTTP retries;
- process crashes;
- webhook duplication;
- network timeouts where send outcome is initially ambiguous;
- multiple application instances.
A logical message/follow-up should have a durable idempotency identity so retrying the operation does not accidentally contact the customer twice.
Business-initiated WhatsApp messaging may require approved provider templates depending on Meta policy and the conversation window.
Dokene should maintain a semantic template layer, for example:
GENERAL_FOLLOW_UP
REPEAT_PURCHASE
RELATED_PRODUCT
SEASONAL_EVENT
DORMANT_CUSTOMER
Application code maps allowed semantic templates to tenant/provider template configuration.
The template registry (ADR 0025) stores one mapping per semantic intent and tenant: provider template name, language, ordered parameter names and an enabled flag, behind GET /api/templates, PUT /api/templates/{templateIntent} and POST /api/templates/{templateIntent}/approval-refresh. Approval is provider evidence, never operator input: the refresh asks Meta's Graph API with the tenant's own credential and records the status, check time, business account and mapping revision. A mapping is sendable only when it is enabled, APPROVED for its current revision and business account, and the evidence is younger than dokene.template.approval-max-age (24 hours by default). Editing a mapping drops the evidence and voids open approvals; a failed refresh drops it too. Phase 3 never sends free-form text: with no trusted customer-service-window evidence it sends only a verified template. The Graph API host is allow-listed (graph.facebook.com, loopback test servers, or hosts explicitly listed in dokene.integration.whatsapp.graph.additional-trusted-hosts) because tenant credentials are sent to it. Approval evidence does not yet prove the template's placeholders match the stored parameters; that check is a required gate before live dispatch (#138).
The AI must not invent provider template IDs.
Provider delivery/read/failure callbacks enter through a dedicated untrusted boundary.
Webhook handling should:
- verify the provider signature/authentication mechanism;
- validate the expected schema;
- resolve the owning integration and tenant from trusted stored configuration;
- deduplicate events;
- reject unknown provider/integration mappings;
- persist or enqueue processing before expensive work where appropriate;
- update message state through valid transitions;
- create appropriate audit records.
An integration record may contain non-secret metadata such as:
- tenant;
- provider type;
- external account/phone-number identifiers;
- enabled/disabled state;
- template mappings;
- webhook metadata;
- secret reference;
- creation/update timestamps.
Reusable credentials should live in a secret store or secret-management abstraction, not in ordinary plaintext application columns.
tenant_messaging_integrations holds one META_WHATSAPP record per tenant under forced row level security (runtime grants SELECT, INSERT, UPDATE). The Meta phone_number_id and business_account_id are numeric identifiers and are stored apart from the optional human display_phone_number (E.164). (provider, phone_number_id) is unique among verified mappings only (V24, issue 210): several tenants may hold an unverified claim of one number, but only the tenant that proves ownership is ever routed to or sends from it.
| Route | Permission | Behaviour |
|---|---|---|
GET /api/integrations/whatsapp |
INTEGRATION_READ (all roles) |
Returns enabled, outboundEnabled, the identifiers, displayPhoneNumber, secretRef, webhookVerified and version with a strong ETag; 404 NOT_FOUND when none exists. outboundEnabled (tenant kill switch, #144) is fixed to false, fail closed, until that feature persists its state. webhookVerified is the stored ownership proof for the current number, business account and credential revision. |
PUT /api/integrations/whatsapp |
INTEGRATION_MANAGE (OWNER, ADMIN) |
Exactly one precondition is required, else 400: If-None-Match: * creates the record (409 STALE_VERSION if one already exists) and If-Match: "<version>" replaces it (stale 409 STALE_VERSION). Any well-formed phoneNumberId is accepted and stored unverified; a PUT is never throttled. Changing phoneNumberId, businessAccountId or secretRef, or disabling the integration, clears the ownership proof; re-enabling does not restore it. |
POST /api/integrations/whatsapp/credential-rotations |
INTEGRATION_MANAGE (OWNER, ADMIN) |
Records that the secret behind the unchanged secretRef was replaced in the store. Strong If-Match, no body; bumps credential_revision and the version, clears the ownership proof and is audited; 404 when no integration exists. |
POST /api/integrations/whatsapp/ownership-verifications |
INTEGRATION_MANAGE (OWNER, ADMIN) |
Proves at Meta, with the tenant's own credential, that the stored business account lists the stored phone number, and records the proof (webhookVerified=true). Strong If-Match, no body; bumps the version and is audited. 400 INVALID_INPUT when not proven or when another tenant already verified the number (indistinguishable), 400 INVALID_INPUT for a disabled integration (no Meta call, no budget), 503 PROVIDER_UNAVAILABLE when Meta gives no conclusion or the tenant's token is invalid or expired (401), 409 STALE_VERSION, 404, and 429 RATE_LIMITED while the rejected-proof budget is spent. |
- Tenant-selection failures are rejected by
TenantContextRequestFilterbefore the controller, with unchanged statuses and audit, and use the closed problem+json shape on every Phase 3 route (/api/messages/**,/api/customers/{customerId}/messages,/api/integrations/**,/api/templates/**): a missingX-Tenant-Idor no membership is403 FORBIDDEN, a duplicated or malformed one is400 INVALID_INPUT. Each module claims its own routes through aTenantSelectionRejectionResponder(ADR 0024, issue 207 addendum); other endpoints keep the empty-body400/403. - Tenant and actor come from the verified context, never the body. Unknown or wrongly typed members, including any raw token, are rejected with
400 INVALID_INPUT. - The body carries a
secretRef, a bare key (^[A-Za-z0-9_-]{1,128}$). Only the reference is stored: it is resolved byIntegrationSecretResolverinside the provider adapter, at send time or for an explicit ownership verification (fail closed: unknown, missing or wrong-scope references block the operation), never when configuration is written, so the write path needs no resolver. Secret values are never stored, returned, logged or audited, and writing configuration never contacts Meta. - Ownership verification (issue 210):
-
PUTnever conflicts on the number, so naming another tenant's number neither blocks it nor reveals that it is in use. - A proof that Meta confirms but that another tenant's verified mapping blocks is answered exactly like an unproven number. That is the only remaining availability signal, and it is throttled (issue 208): each
400refusal consumes a budget per tenant and per actor in a sliding window, configured bydokene.integration.whatsapp.mapping-rejection-limit(tenant-rejections10,actor-rejections5,window15m). -
503 PROVIDER_UNAVAILABLE, stale preconditions, successful proofs and configuration writes consume nothing. Each proof reserves a unit before calling Meta, so parallel attempts cannot exceed the budget. - While either budget is spent, every proof gets
429with the closedRATE_LIMITEDproblem andRetry-After, and Meta is not called. Configuration writes and credential rotations are never throttled, so disabling always works. - Every refused proof appends one
AUTHORIZATION_DENIED(INTEGRATION_MANAGE) in its own transaction: reasonMAPPING_REJECTEDfor each400andRATE_LIMITEDfor each429. No candidate id appears in responses, logs or audit rows. - The budget lives in memory per instance; the remaining risk is tracked in the threat model.
- Until a production secret resolver exists (#142), every proof fails closed with
503 PROVIDER_UNAVAILABLE.
-
- Rotating a secret in place (same
secretRef): replace it in the store, thenPOST /api/integrations/whatsapp/credential-rotationswithIf-Match(no body). ChangingsecretRefbumpscredential_revisiontoo, which is part of the approval sender identity: open approvals then fail the send withAPPROVAL_INVALIDATED. Enabling or disabling does not void approvals; the enabled flag is read live at send time. - The
ETagis the configuration concurrency token (the storedversion); recording or clearing a proof bumps it.outboundEnabledis live runtime state, soGETandPUTsendCache-Control: no-storeand never answer304. -
Reclaiming a pre-registered number. If another tenant mapped your number first, configure it as usual and call
ownership-verifications. The other tenant's unverified row is inert: it is never routed to, never sends, and cannot be verified without a credential that reaches the number. Neither tenant sees the other's record. Disabling an integration releases its number. Only if a number moved at Meta while the old tenant stays enabled with a stale proof does a platform operator have to clear it (ADR 0024, issue 210 addendum). - Webhook ingress (#139) resolves a tenant only through
VerifiedPhoneNumberMappings(the security-definerdokene.resolve_verified_whatsapp_tenant), which returns the tenant id of a verified, enabled mapping and nothing else.OutboundSafetyControl.evaluatechecks the global switch, the tenant switch and then the mapping, in that order; a disabled integration or one without a current proof isINTEGRATION_DISABLED. - Each write appends
INTEGRATION_UPDATEDin the same transaction with theenabledflag only. If the audit append fails, the change rolls back. - The state behind the global and tenant switches in
OutboundSafetyControl.evaluatebelongs to the kill switch work (#144); until then the global switch blocks every send.
For Meta WhatsApp Cloud API, distinguish clearly between:
- the human-visible WhatsApp phone number;
- Meta's internal phone-number identifier;
- WhatsApp Business Account identifiers;
- access credentials/tokens;
- provider message IDs.
These values have different security and identity semantics and should not be conflated.
Before every send, deterministic application policy should verify at least:
- recipient belongs to the active tenant context;
- recipient/channel is valid;
- tenant outbound messaging is enabled;
- customer is contact-eligible;
- customer has not opted out;
- contact-frequency rules permit the send;
- actor/automation policy is authorized;
- message has not already been sent;
- selected template/action is allowed;
- integration is enabled and healthy enough to attempt delivery.
Provider errors should be normalized into useful application categories while preserving safe diagnostic detail.
Do not expose raw secret-bearing provider payloads in logs or UI errors.
Retries should distinguish transient from permanent failures.
The Meta adapter reports four result classes (ADR 0027 §4):
-
Accepted: Meta returned a message id. Acceptance is not delivery. -
RejectedPermanently: a definitive refusal, normalized to aFailureCategory. -
FailedTransiently: a definitive refusal that proves nothing was taken, such as throttling, a 4xx "service unavailable" code, an unreachable provider or a failed TLS handshake. -
OutcomeUnknown: a timeout after sending, a redirect, any 5xx (whatever Meta code it carries), or an unusable success body.
OutcomeUnknown is never retried automatically. Only the integer Meta error code is read; error text, trace ids and bodies are discarded.
Potential future channels include:
- email;
- SMS;
- RCS;
- push notifications;
- other business messaging systems.
Adding a channel must not weaken consent, audit, authorization, or idempotency guarantees.
- Home
- Product Vision
- Architecture
- Security Model
- Multi Tenancy and Data
- AI and Automation
- Messaging and Integrations
- Development Model
- Roadmap
Canonical source: docs/wiki/ in stevdrey/dokene.
Durable decisions: docs/adr/.